注册 登录
电子工程世界-论坛 返回首页 EEWORLD首页 频道 EE大学堂 下载中心 Datasheet 专题
雷龙发展的个人空间 https://home.eeworld.com.cn/space-uid-1012764.html [收藏] [复制] [分享] [RSS]
日志

【嵌入式SD NAND】基于FATFS/Littlefs文件系统的日志框架实现

热度 1已有 353 次阅读2024-3-13 12:00

文章目录

 

【嵌入式】基于FATFS/Littlefs文件系统的日志框架实现

1. 概述

2. 设计概要

3. 设计实现

3.1 初始化 `init`

3.2 日志写入 `write`

3.3 日志读取 `read`

3.4 注销 `deinit`

3.5 全部代码汇总

4. 测试

5. 总结

 

1. 概述

 

那么在移植好了文件系统之后,我们又应该如何应用文件系统呢?

 

很多人会说,这个简单,就操作文件嘛!open、read、write、close不就行了吗!当然对于简单的使用,掌握open、read、write、close,去存储一两个文件或者从一两个文件中简单的读取下数据这确实没有什么难度。但在实际应用中,特别是产品开发过程中,往往不只是简单的操作一两个文件就可以的,如果真是这样,那费那么大劲移植文件系统多少有点浪费!

 

在实际项目开发中,往往需要依托文件系统操作诸多的文件,操作诸多的数据。如通过配置文件配置机器设备信息、通过升级文件进行产品升级、通过存放字库文件实现多语言支持等等,这些都是比较简单的操作,读写不是很频繁,相对来说实现比较简单,还有一类需求读写会相当频繁,且大多数产品内都希望存在的,那便是日志文件,通过日志文件来记录设备的运行数据。日志文件不同于其他功能,其往往需要具备几个基本特性需求:

 

  • 单个文件大小限制

  • 日志总大小空间占用限制

  • 自动循环覆盖

 

网上也有一些开源的日志框架,如 Log4j,不过大都是基于 java / c ++ 实现的,虽然功能比较全面,但比较繁杂,且也难以移植应用于嵌入式开发中。而在嵌入式开发中,可能也受限于资源限制,并没有发现不错的基于文件系统的开源日志框架(至少博主目前没有发现,有的话欢迎大家评论区讨论 ),对于如何实现一个日志框架很多人一下子可能没有头绪,综上,本文将分享一个简单的基于文件系统的日志程序以供大家思考。

2. 设计概要

 

我们需要实现的日志模块的核心需求为:

 

  • 单个文件大小限制

  • 日志总大小空间占用限制

  • 自动循环覆盖

对于一个模块,对外仅需提供其操作的接口即可,内部的算法实现均无需对外开放,而对于此日志模块,对外只需提供基本的以下四个接口即可:

 

  • 初始化 init

  • 写日志 write

  • 读日志 read

  • 注销 deinit

关于日志存储的核心思想如下:

 

写数据之前先判断当前操作的文件是否超出单个文件大小限制,如超出大小限制则进行日志轮转,创建一个新的日志文件并判断日志文件总大小是否超出限制,如果超出则删除最早的那一份日志文件

 

关于日志存储的详细设计如下:

 

日志文件格式采用:<filename>.log ,当当前文件达到单个文件大小之后,进行文件轮转;

 

假定当前限制日志每个日志文件大小为2048Byte,最多存储10个文件;

 

当当前文件达到单个文件大小之后,迭代修改日志文件名:

 

  • <filename>.log -> <filename>.log.0

  • <filename>.log.0 -> <filename>.log.1

  • <filename>.log.1 -> <filename>.log.2

  • <filename>.log.8 -> <filename>.log.9

  • 删除 <filename>.log.9

ps:注意实际代码操作的时候,文件修改顺序是反过来的,也就是先 删除 <filename>.log.9 再将<filename>.log.8 -> <filename>.log.9

3. 设计实现

3.1 初始化 init

 

初始化部分代码主要功能是完成日志数据结构体的构造,并通过传入参数 log_file_cfg_t cfg 配置日志文件的配置信息,如单个日志文件大小、日志文件名、最多存放的日志文件数等内容,日志模块初始化部分代码如下:

  1. log_file_t log_storage_init(log_file_cfg_t cfg)

  2. {

  3.     log_file_t log = NULL;

  4.     log_file_cfg_t log_cfg = NULL;

  5.     log_file_read_t log_read = NULL;

  6.  

  7.     log = (log_file_t)malloc(sizeof(struct log_file_config));

  8.     if (log == NULL)

  9.         goto error;

  10.     

  11.     log_cfg = (log_file_cfg_t)malloc(sizeof(struct log_file_config));

  12.     if (log_cfg == NULL) {

  13.         free(log);

  14.         log = NULL;

  15.         goto error;

  16.     }

  17.  

  18.     log_read = (log_file_read_t)malloc(sizeof(struct log_file_read));

  19.     if (log_read == NULL) {

  20.         free(log);

  21.         log = NULL;

  22.         free(log_cfg);

  23.         log_cfg = NULL;

  24.         goto error;

  25.     }

  26.  

  27.     memcpy(log_cfg, cfg, sizeof(struct log_file_config));

  28.     log_read->rotate_index = 0;

  29.     log_read->file_offset = 0;

  30.     

  31.     log->cfg = log_cfg;

  32.     log->read = log_read;

  33.     log->user_data = NULL;

  34.  

  35. error:

  36.     return log;

  37. }

3.2 日志写入 write

 

日志写入部分代码主要分为两大部分,一部分是正常写入,另一部分是文件轮转;当写入的文件超过单个文件大小限制时,即会触发文件轮转操作。

 

在文件轮转中,主要做的是:创建一个新的日志文件并判断日志文件总大小是否超出限制,如果超出则删除最早的那一份日志文件,具体设计细节可参考上文设计概要中的详细设计部分。

 

实现代码如下:

  1. static int log_rotate(log_file_t log)

  2. {

  3.     int ret = 0;

  4.     FILE *fp;

  5.     char old_filename[NAME_MAX + 10] = {0};

  6.     char new_filename[NAME_MAX + 10] = {0};

  7.  

  8.     for (int i = log->cfg->rotate_num; i > 0; i --) {

  9.         memset(old_filename, 0, sizeof(old_filename));

  10.         memset(new_filename, 0, sizeof(new_filename));

  11.  

  12.         snprintf(old_filename, sizeof(old_filename), i ? "%s_%d.log" : "%s.log", log->cfg->filename, i - 1);

  13.         snprintf(new_filename, sizeof(new_filename), "%s_%d.log", log->cfg->filename, i);

  14.  

  15.         printf("old:%s new:%s\n", old_filename, new_filename);

  16.         

  17.         if ((fp = fopen(new_filename, "r")) != NULL) {

  18.             if (fclose(fp) != 0) {

  19.                 ret = -1;

  20.                 goto error;

  21.             }

  22.             if (remove(new_filename) != 0) {

  23.                 ret = -2;

  24.                 goto error;

  25.             }

  26.         }

  27.  

  28.         if ((fp = fopen(old_filename, "r")) != NULL) {

  29.             if (fclose(fp) != 0) {

  30.                 ret = -1;

  31.                 goto error;

  32.             }

  33.             if (rename(old_filename, new_filename) != 0) {

  34.                 ret = -3;

  35.                 goto error;

  36.             }

  37.         }

  38.     }

  39.  

  40. error:

  41.     return ret;

  42. }

  43.  

  44. int log_storage_write(log_file_t log, const unsigned char *buf, unsigned int len)

  45. {

  46.     int ret = 0;

  47.     int file_size = 0;

  48.     char full_filename[NAME_MAX + 5] = {0};

  49.     FILE *fp = NULL;

  50.  

  51.     if (log == NULL || log->cfg == NULL || log->read == NULL || buf == NULL || len == 0) {

  52.         ret = -1;

  53.         goto param_error;

  54.     }

  55.  

  56.     snprintf(full_filename, sizeof(full_filename), "%s.log", log->cfg->filename);

  57.     

  58.     printf("fullfilename:%s\n", full_filename);

  59.     log_file_lock();

  60.  

  61.     fp = fopen(full_filename, "a+b");

  62.     if (fp == NULL) {

  63.         ret = -2;

  64.         goto error;

  65.     }

  66.  

  67.     fseek(fp, 0L, SEEK_END);

  68.     file_size = ftell(fp);

  69.     printf("file_size:%d\n", file_size);

  70.     if ((file_size + len) > log->cfg->max_size) {

  71.         if (fclose(fp) != 0) {

  72.             ret = -3;

  73.             goto error;

  74.         }

  75.  

  76.         int j = 0;

  77.         j = log_rotate(log);

  78.         printf("log rotate:%d\n", j);

  79.         fp = fopen(full_filename, "a+b");

  80.         if (fp == NULL) {

  81.             ret = -2;

  82.             goto error;

  83.         }

  84.     }

  85.  

  86.     if (fwrite(buf, len, 1, fp) != 1) {

  87.         fclose(fp);

  88.         ret = -4;

  89.         goto error;

  90.     }

  91.  

  92. error:

  93. if (fp != NULL) {

  94. if (fclose(fp) != 0) {

  95. ret = -3;

  96. goto error;

  97. }

  98. }

  99.     log_file_unlock();

  100. param_error:

  101.     return ret;

  102. }

3.3 日志读取 read

 

此处日志读取在本文主题中非重点设计内容,因此此处做简单设计,通过传入参数判断应该读取哪一份文件之后进行直接读取。设计代码如下:

  1. int log_storage_read(log_file_t log, unsigned int rotate_num, unsigned char *buf, unsigned int *len)

  2. {

  3.     int ret = 0;

  4.     int file_size = 0;

  5.     char full_filename[NAME_MAX + 5] = {0};

  6.     FILE *fp = NULL;

  7.  

  8.     if (log == NULL || log->cfg == NULL || log->read == NULL || buf == NULL || len == 0) {

  9.         ret = -1;

  10.         goto param_error;

  11.     }

  12.  

  13.     if (rotate_num == 0)

  14.         snprintf(full_filename, sizeof(full_filename), "%s.log", log->cfg->filename);

  15.     else

  16.         snprintf(full_filename, sizeof(full_filename), "%s.log.%d", log->cfg->filename, rotate_num);

  17.  

  18.     log_file_lock();

  19.  

  20.     fp = fopen(full_filename, "a+b");

  21.     if (fp == NULL) {

  22.         ret = -2;

  23.         goto error;

  24.     }

  25.     /* check file length. */

  26.     fseek(fp, 0L, SEEK_END);

  27.     file_size = ftell(fp);

  28.     printf("file_size:%d\n", file_size);

  29.     if (file_size < *len)

  30.         *len = file_size;

  31.  

  32.     fseek(fp, 0L, SEEK_SET);

  33.     if (fread(buf, *len, 1, fp) != 1) {

  34.         ret = -3;

  35.         fclose(fp);

  36.         goto error;

  37.     }

  38.  

  39. error:

  40. if (fp != NULL) {

  41. if (fclose(fp) != 0) {

  42. ret = -4;

  43. goto error;

  44. }

  45. }

  46.     log_file_unlock();

  47. param_error:

  48.     return ret;

  49. }

3.4 注销 deinit

 

注销的主要功能是将我们在 init 时创建的数据结构进行回收,如果模块内部有功能处于打开装填,也应关闭模块的功能,此处我们仅需对 init 时创建的 log_file_t log 数据结构体进行注销、内存回收即可,具体代码实现如下:

  1. int log_storage_deinit(log_file_t log)

  2. {

  3. if (log == NULL)

  4. return -1;

  5.  

  6. if (log->cfg != NULL)

  7. free(log->cfg);

  8. if (log->read != NULL)

  9. free(log->read);

  10. if (log->user_data != NULL)

  11. free(log->user_data);

  12. free(log);

  13. return 0;

  14. }

3.5 全部代码汇总

 

日志模块内核头文件:simple_storage.h

  1. #ifndef __SIMPLE_STORAGE_H__

  2. #define __SIMPLE_STORAGE_H__

  3.  

  4. #define NAME_MAX        40

  5.  

  6. struct log_file_config {

  7.     const char filename[NAME_MAX];     /* Filename of this type. */

  8.     int max_size;       /* single file max size. */

  9.     int rotate_num;     /* The number of files that support rotate. */

  10. };

  11. typedef struct log_file_config* log_file_cfg_t;

  12.  

  13. struct log_file_read {

  14.     int rotate_index;   /* The rotate file index. */

  15.     int file_offset;    /* The offset of the currently read file. */

  16. };

  17. typedef struct log_file_read* log_file_read_t;

  18.  

  19. struct log_file {

  20.     log_file_cfg_t cfg;

  21.     log_file_read_t read;

  22.     void *user_data;

  23. };

  24. typedef struct log_file* log_file_t;

  25.  

  26.  

  27. log_file_t log_storage_init(log_file_cfg_t cfg);

  28. int log_storage_write(log_file_t log, const unsigned char *buf, unsigned int len);

  29. int log_storage_read(log_file_t log, unsigned int rotate_num, unsigned char *buf, unsigned int *len);

  30. int log_storage_deinit(log_file_t log);

  31.  

  32.  

  33. #endif /* __SIMPLE_STORAGE_H__ */

 

日志模块内核文件: simple_storage.c

  1. #include "simple_storage.h"

  2. #include "simple_storage_port.h"

  3. #include <stdio.h>

  4. #include <string.h>

  5.  

  6. log_file_t log_storage_init(log_file_cfg_t cfg)

  7. {

  8.     log_file_t log = NULL;

  9.     log_file_cfg_t log_cfg = NULL;

  10.     log_file_read_t log_read = NULL;

  11.  

  12.     log = (log_file_t)malloc(sizeof(struct log_file_config));

  13.     if (log == NULL)

  14.         goto error;

  15.     

  16.     log_cfg = (log_file_cfg_t)malloc(sizeof(struct log_file_config));

  17.     if (log_cfg == NULL) {

  18.         free(log);

  19.         log = NULL;

  20.         goto error;

  21.     }

  22.  

  23.     log_read = (log_file_read_t)malloc(sizeof(struct log_file_read));

  24.     if (log_read == NULL) {

  25.         free(log);

  26.         log = NULL;

  27.         free(log_cfg);

  28.         log_cfg = NULL;

  29.         goto error;

  30.     }

  31.  

  32.     memcpy(log_cfg, cfg, sizeof(struct log_file_config));

  33.     log_read->rotate_index = 0;

  34.     log_read->file_offset = 0;

  35.     

  36.     log->cfg = log_cfg;

  37.     log->read = log_read;

  38.     log->user_data = NULL;

  39.  

  40. error:

  41.     return log;

  42. }

  43.  

  44. static int log_rotate(log_file_t log)

  45. {

  46.     int ret = 0;

  47.     FILE *fp;

  48.     char old_filename[NAME_MAX + 10] = {0};

  49.     char new_filename[NAME_MAX + 10] = {0};

  50.  

  51.     for (int i = log->cfg->rotate_num; i > 0; i --) {

  52.         memset(old_filename, 0, sizeof(old_filename));

  53.         memset(new_filename, 0, sizeof(new_filename));

  54.  

  55.         snprintf(old_filename, sizeof(old_filename), i ? "%s_%d.log" : "%s.log", log->cfg->filename, i - 1);

  56.         snprintf(new_filename, sizeof(new_filename), "%s_%d.log", log->cfg->filename, i);

  57.  

  58.         printf("old:%s new:%s\n", old_filename, new_filename);

  59.         

  60.         if ((fp = fopen(new_filename, "r")) != NULL) {

  61.             if (fclose(fp) != 0) {

  62.                 ret = -1;

  63.                 goto error;

  64.             }

  65.             if (remove(new_filename) != 0) {

  66.                 ret = -2;

  67.                 goto error;

  68.             }

  69.         }

  70.  

  71.         if ((fp = fopen(old_filename, "r")) != NULL) {

  72.             if (fclose(fp) != 0) {

  73.                 ret = -1;

  74.                 goto error;

  75.             }

  76.             if (rename(old_filename, new_filename) != 0) {

  77.                 ret = -3;

  78.                 goto error;

  79.             }

  80.         }

  81.     }

  82.  

  83. error:

  84.     return ret;

  85. }

  86.  

  87. int log_storage_write(log_file_t log, const unsigned char *buf, unsigned int len)

  88. {

  89.     int ret = 0;

  90.     int file_size = 0;

  91.     char full_filename[NAME_MAX + 5] = {0};

  92.     FILE *fp = NULL;

  93.  

  94.     if (log == NULL || log->cfg == NULL || log->read == NULL || buf == NULL || len == 0) {

  95.         ret = -1;

  96.         goto param_error;

  97.     }

  98.  

  99.     snprintf(full_filename, sizeof(full_filename), "%s.log", log->cfg->filename);

  100.     

  101.     printf("fullfilename:%s\n", full_filename);

  102.     log_file_lock();

  103.  

  104.     fp = fopen(full_filename, "a+b");

  105.     if (fp == NULL) {

  106.         ret = -2;

  107.         goto error;

  108.     }

  109.  

  110.     fseek(fp, 0L, SEEK_END);

  111.     file_size = ftell(fp);

  112.     printf("file_size:%d\n", file_size);

  113.     if ((file_size + len) > log->cfg->max_size) {

  114.         if (fclose(fp) != 0) {

  115.             ret = -3;

  116.             goto error;

  117.         }

  118.  

  119.         int j = 0;

  120.         j = log_rotate(log);

  121.         printf("log rotate:%d\n", j);

  122.         fp = fopen(full_filename, "a+b");

  123.         if (fp == NULL) {

  124.             ret = -2;

  125.             goto error;

  126.         }

  127.     }

  128.  

  129.     if (fwrite(buf, len, 1, fp) != 1) {

  130.         fclose(fp);

  131.         ret = -4;

  132.         goto error;

  133.     }

  134.  

  135. error:

  136. if (fp != NULL) {

  137. if (fclose(fp) != 0) {

  138. //TODO: check the amount of disk space, delete if there is not enough space.

  139. ret = -3;

  140. goto error;

  141. }

  142. }

  143.     log_file_unlock();

  144. param_error:

  145.     return ret;

  146. }

  147.  

  148. int log_storage_read(log_file_t log, unsigned int rotate_num, unsigned char *buf, unsigned int *len)

  149. {

  150.     int ret = 0;

  151.     int file_size = 0;

  152.     char full_filename[NAME_MAX + 5] = {0};

  153.     FILE *fp = NULL;

  154.  

  155.     if (log == NULL || log->cfg == NULL || log->read == NULL || buf == NULL || len == 0) {

  156.         ret = -1;

  157.         goto param_error;

  158.     }

  159.  

  160.     if (rotate_num == 0)

  161.         snprintf(full_filename, sizeof(full_filename), "%s.log", log->cfg->filename);

  162.     else

  163.         snprintf(full_filename, sizeof(full_filename), "%s.log.%d", log->cfg->filename, rotate_num);

  164.  

  165.     log_file_lock();

  166.  

  167.     fp = fopen(full_filename, "a+b");

  168.     if (fp == NULL) {

  169.         ret = -2;

  170.         goto error;

  171.     }

  172.     /* check file length. */

  173.     fseek(fp, 0L, SEEK_END);

  174.     file_size = ftell(fp);

  175.     printf("file_size:%d\n", file_size);

  176.     if (file_size < *len)

  177.         *len = file_size;

  178.  

  179.     fseek(fp, 0L, SEEK_SET);

  180.     if (fread(buf, *len, 1, fp) != 1) {

  181.         ret = -3;

  182.         fclose(fp);

  183.         goto error;

  184.     }

  185.  

  186. error:

  187. if (fp != NULL) {

  188. if (fclose(fp) != 0) {

  189. ret = -4;

  190. goto error;

  191. }

  192. }

  193.     log_file_unlock();

  194. param_error:

  195.     return ret;

  196. }

  197.  

  198. int log_storage_deinit(log_file_t log)

  199. {

  200. if (log == NULL)

  201. return -1;

  202.  

  203. if (log->cfg != NULL)

  204. free(log->cfg);

  205. if (log->read != NULL)

  206. free(log->read);

  207. if (log->user_data != NULL)

  208. free(log->user_data);

  209. free(log);

  210. return 0;

  211. }

 

在日志模块源文件的代码中,我们可以看到实际每次操作文件的时候,都有调用一个函数锁操作,考虑到不同平台的锁操作实现不一样,因此将此部分通过函数导出来,放置在模块的端口文件中。不同的平台、系统根据各自的平台和系统的情况进行实现,如像裸机编程这类不需要进行锁操作的不进行函数实现即可。

 

日志模块端口头文件:simple_storage_port.c

  1. #ifndef __SIMPLE_STORAGE_PORT_H__

  2. #define __SIMPLE_STORAGE_PORT_H__

  3.  

  4. int log_file_init(void);

  5. int log_file_lock(void);

  6. int log_file_unlock(void);

  7.  

  8.  

  9. #endif /* __SIMPLE_STORAGE_PORT_H__ */

     

 

日志模块端口源文件:simple_storage_port.h

  1. #include "simple_storage_port.h"

  2.  

  3. int log_file_init(void)

  4. {

  5.     return 0;

  6. }

  7.  

  8. int log_file_lock(void)

  9. {

  10.     return 0;

  11. }

  12.  

  13. int log_file_unlock(void)

  14. {

  15.     return 0;

  16. }

4. 测试

 

将以上代码进行运行测试,硬件平台如下:

 

  • 控制器: stm32f103vet6,野火指南者开发板

     

  • 存储芯片: CS创世 SD nand,型号:CSNP4GCR01-AMW

     

  • 文件系统: FATFS,注意此日志不受文件系统限制

     

  • 操作系统: RT-Thread,此模块与操作系统无关,此处只是方便使用故自行移植了rtthread

EEWORLDIMGTK0

应用层代码如下:

 

  1. int main(void)

  2. {

  3. /* Reset of all peripherals, Initializes the Flash interface and the Systick. */

  4. HAL_Init();

  5.  

  6. /* USER CODE BEGIN Init */

  7.  

  8. /* USER CODE END Init */

  9.  

  10. /* Configure the system clock */

  11. SystemClock_Config();

  12.  

  13. /* USER CODE BEGIN SysInit */

  14.  

  15. /* USER CODE END SysInit */

  16.  

  17. /* Initialize all configured peripherals */

  18. MX_GPIO_Init();

  19. MX_SDIO_SD_Init();

  20. MX_USART1_UART_Init();

  21. MX_FATFS_Init();

  22.  

  23. /* USER CODE BEGIN 2 */

  24. struct log_file_config log_cfg = {

  25. .filename = "test",

  26. .max_size = 2048,

  27. .rotate_num = 10,

  28. };

  29. log_file_t log = NULL;

  30. log = log_storage_init(&log_cfg);

  31. if (log == NULL)

  32. return;

  33. /* USER CODE END 2 */

  34.  

  35. /* Infinite loop */

  36. /* USER CODE BEGIN WHILE */

  37. unsigned char buf[2048] = {0};

  38. int len = 0;

  39. while (1) {

  40. // ... 省略用户代码

  41. /* 写入测试 */

  42. for (int i = 0; i < 2048; i++) {

  43. log_storage_write(log, "hello world", sizeof("hello world"));

  44. rt_thread_mdelay(100);

  45. }

  46.  

  47. /* 读取测试 */

  48. len = sizeof(buf);

  49. memset(buf, 0, sizeof(buf));

  50. log_storage_read(log, 1, buf, &len);

  51. for (int i = 0; i < len; i ++)

  52. rt_kprintf("%c", buf);

  53. rt_thread_mdelay(1000);

  54. }

  55. }

 

测试结果如下:

 

  1. msg> hello worldhello world hello world hello world hello world hello world hello world hello world hello world ...省略

  2.  

  3. msh > ls

  4. test.log    2046

  5. test.log.0     2046

  6. test.log.1     2046

  7. test.log.2     2046

  8. test.log.3     2046

  9. test.log.4     2046

 

5. 总结

 

综上便是基于文件系统的简易日志模块设计的全部内容了,虽然简陋了点,但相信对于大部分没有接触过日志系统设计的人来说提供了很好的一条设计思路。

 

也正因为简易,给大家对于日志系统设计的优化留足了大量的优化空间。比如:

 

文件轮转的时候需要对每个文件的文件名进行修改,是否可以有更好的方式不用每个文件都修改呢?

文件名的设计是不方便阅读的,是否可以引入时间参数?

文件名设计如何引入了时间参数,当设备RTC备用电池掉电的时候又如何保证文件不会被错误覆盖?

文件的读取显然优化空间更大,实际上用户不应该传入rotate_num 参数,因为这是模块内部的参数,用户不可感知的

文件读取如何做到分多次读取一个文件的内容,且不会重复,是顺序读取?

等等,以上只是我简单想到的几点内容,大家不妨思考下如何实现方案更好呢?当然又还有哪些需求是需要引入的呢,也欢迎大家在评论区留言,关注我,后续抽时间再分享下改良版日志系统!!!

本文来自论坛,点击查看完整帖子内容。

发表评论 评论 (3 个评论)
回复 codeword 2024-9-24 07:49
  
回复 codeword 2024-11-3 21:02
  
回复 codeword 2024-11-10 07:16
  

facelist doodle 涂鸦板

您需要登录后才可以评论 登录 | 注册