返回列表 发新帖本帖赏金: 80.00元(功能说明)

[APM32F1] APM32F103的IAP方案

[复制链接]
232|3
口天土立口 发表于 2026-3-20 08:47 | 显示全部楼层 |阅读模式
本帖最后由 口天土立口 于 2026-3-20 19:05 编辑

#申请原创# #技术资源#
@21小跑堂

1. IAP介绍
IAP主要由Boot+App两个程序组成,Boot出厂即已固化,一般不会再更改,而App则是可以随时更新的程序,也是日常运行的程序。IAP能方便产品出货后,随时更改App,以修正bug或更新需求,是非常常用的手段,非常的嵌入式产品都支持此功能。

2. 硬件
APM32F103VB MINI板

3. IAP详细方案
IAP首先需要规划好Flash的分布,Boot区毫无疑问是放在最前面,因为上电运行都是从Boot区开始运行。后面的其他区域,则随意安排即可。
详细规划如下:Boot区固定位于最前面,大小为3KB;紧接为APP运行区,大小为60KB;后面接上APP下载IAP区,大小保持与APP运行区一致;最后2个是参数区,参数一般为2个一样大小的区域,一备一用,当前面区域的数据检测到异常,则使用后面的参数区数据。
  1. /* FLASH起始地址 */
  2. #define FLASH_START_ADDR    (FMC_BASE)
  3. /* FLASH总大小 */
  4. #define FLASH_TOTAL_SIZE    ((uint32_t)0x20000)     /* 128KB */
  5. /* APP大小 */
  6. #define APP_TOTAL_SIZE      ((uint32_t)1024 * 50)   /* 50KB */
  7. /* 参数存储大小 */
  8. #define PARAM_SIZE          ((uint32_t)1024 * 2)    /* 2KB */
  9. /* 版本号偏移位置: 软件版本号 - 位于存储的前第1KB处 */
  10. #define VERSION_OFFSET      ((uint32_t)0x400)       /* 1KB */

  11. /* BOOT起始地址 */
  12. #define BOOT_START_ADDR     FLASH_START_ADDR
  13. /* BOOT大小 */
  14. #define BOOT_TOTAL_SIZE     ((uint32_t)0xC00)       /* 3KB */

  15. /* 运行APP起始地址 */
  16. #define APP_RUN_START_ADDR  (BOOT_START_ADDR + BOOT_TOTAL_SIZE)
  17. /* 运行APP大小 */
  18. #define APP_RUN_TOTAL_SIZE  APP_TOTAL_SIZE          /* 50KB */

  19. /* 升级APP起始地址 */
  20. #define APP_IAP_START_ADDR  (APP_RUN_START_ADDR + APP_RUN_TOTAL_SIZE)
  21. /* 升级APP大小 */
  22. #define APP_IAP_TOTAL_SIZE  APP_TOTAL_SIZE          /* 50KB */

  23. /* 参数存储区1起始地址 */
  24. #define PARAM1_START_ADDR    (APP_IAP_START_ADDR + APP_IAP_TOTAL_SIZE)  /* 2KB */
  25. #define PARAM1_TOTAL_SIZE    PARAM_SIZE

  26. /* 参数存储区2起始地址 */
  27. #define PARAM2_START_ADDR    (PARAM1_START_ADDR + PARAM1_TOTAL_SIZE)    /* 2KB */
  28. #define PARAM2_TOTAL_SIZE    PARAM_SIZE

Boot的main.c代码如下:
指定Boot的软件版本位于自身区域的前第1KB处,目的是方便App区程序运行过程中能随时准确获取得到Boot的版本。
同时,程序开始运行时,需指定中断向量表地址为Boot区起始地址(程序的开头都是中断向量表)。
  1. // 软件版本号 - 位于存储的前第1KB处
  2. static const char software_version[10] __attribute__((at(BOOT_START_ADDR + VERSION_OFFSET))) = {
  3.     'B', 'O', 'O', 'T', '_', '0', '1', '.', '1', '0'
  4. };

  5. /*!
  6. * @brief       Main program
  7. *
  8. * @param       None
  9. *
  10. * @retval      None
  11. *
  12. */
  13. int main(void)
  14. {
  15.     /* 设置中断向量表地址 */
  16.     SCB->VTOR = BOOT_START_ADDR;
  17.    
  18.     bsp_init();
  19.     app_init();
  20.    
  21.     while (1) {
  22.         app_task();
  23.     }
  24. }

Boot每次上电都检查升级完成标志,此标志为固定的字符串“IAP COMPLETE”,当标志匹配,则执行后续的App擦除更新流程。
App的擦除和拷贝都尝试两次,当完成流程后,只需要把开头的扇区擦除即可,因为判断是否需要升级的所有标志和数据,都在区域的起始扇区内。
检查升级包的过程中,会读取IAP区的所有数据,并计算CRC_8校验,此操作是为了确保IAP区内的程序数据是有效未被非法修改的,否则异常数据拷贝到App运行区使用可能会产生非预期的异常。
  1. // 应用初始化
  2. void app_init(void)
  3. {
  4.     app_iap_check_and_copy();
  5.     app_iap_jump_to_run_app();
  6. }

  7. /*
  8. * @brief       检查IAP升级程序并拷贝
  9. *
  10. * @param       None
  11. *
  12. * @retval      None
  13. *
  14. */
  15. void app_iap_check_and_copy(void)
  16. {
  17.     uint8_t *iap_complete_f = (uint8_t *)APP_IAP_START_ADDR;
  18.     uint32_t app_size = 0;
  19.     uint8_t crc = 0;
  20.    
  21.     /* 检查升级完成标志 */
  22.     if (memcmp(IAP_COMPLETE_FLAG, iap_complete_f, sizeof(IAP_COMPLETE_FLAG)) != 0) {
  23.         return;
  24.     }

  25.     /* 检查升级包 */
  26.     app_size = *(uint32_t *)(APP_IAP_START_ADDR + sizeof(IAP_COMPLETE_FLAG));
  27.     crc = *(uint8_t *)(APP_IAP_START_ADDR + IAP_APP_OFFSET + app_size - 1);
  28.     if (crc != crc8_calc(0, (uint8_t *)(APP_IAP_START_ADDR + IAP_APP_OFFSET), (app_size - 1))) {
  29.         return ;
  30.     }

  31.     /* 擦除运行APP */
  32.     if (bsp_flash_erase(APP_RUN_START_ADDR, APP_RUN_TOTAL_SIZE) < APP_RUN_TOTAL_SIZE) {
  33.         if (bsp_flash_erase(APP_RUN_START_ADDR, APP_RUN_TOTAL_SIZE) < APP_RUN_TOTAL_SIZE) {
  34.             return ;
  35.         }
  36.     }

  37.     /* 拷贝升级包到运行APP区 */
  38.     if (bsp_flash_write(APP_RUN_START_ADDR,
  39.                 (uint8_t *)(APP_IAP_START_ADDR + IAP_APP_OFFSET), app_size) < app_size) {
  40.         if (bsp_flash_write(APP_RUN_START_ADDR,
  41.                 (uint8_t *)(APP_IAP_START_ADDR + IAP_APP_OFFSET), app_size) < app_size)  {
  42.             /* 异常则擦除头部 */
  43.             if (bsp_flash_erase(APP_RUN_START_ADDR, IAP_APP_OFFSET) < IAP_APP_OFFSET) {
  44.                 if (bsp_flash_erase(APP_RUN_START_ADDR, IAP_APP_OFFSET) < IAP_APP_OFFSET) {
  45.                     return ;
  46.                 }
  47.             }
  48.             return ;
  49.         }
  50.     }

  51.     /* 擦除IAP程序头部1页大小 */
  52.     if (bsp_flash_erase(APP_IAP_START_ADDR, IAP_APP_OFFSET) < IAP_APP_OFFSET) {
  53.         if (bsp_flash_erase(APP_IAP_START_ADDR, IAP_APP_OFFSET) < IAP_APP_OFFSET) {
  54.             return ;
  55.         }
  56.     }
  57. }

跳转App运行前,需要检查程序开始的4个字节栈地址是否在合法的SRAM地址范围内,编译产生的bin,起始4个字节是栈地址。
因为system.c文件已经将MCU的主频配置为PLL倍频的时钟,不是使用MCU默认上电的HSI时钟,所以跳转前改为HSI时钟,让App开始运行时,如同刚从复位上电时运行一样。
  1. /*
  2. * @brief       检查程序堆栈
  3. *
  4. * @param       app_addr: 程序地址
  5. *
  6. * @retval      0-成功, 其他-失败
  7. *
  8. */
  9. static int8_t iap_app_sp_check(uint32_t app_addr)
  10. {
  11.     memcpy((void *)&vec_tab, (uint32_t *)(app_addr), sizeof(struct arm_vector_table));
  12.     /* 指针需在SRAM的起始范围内 */
  13.     if (((vec_tab.msp) & 0xFFFE0000) != SRAM_BASE) {
  14.         return -1;
  15.     }
  16.     return 0;
  17. }

  18. /*
  19. * @brief       跳转运行APP
  20. *
  21. * @param       None
  22. *
  23. * @retval      None
  24. *
  25. */
  26. void app_iap_jump_to_run_app(void)
  27. {
  28.     /* 检查新的运行程序 */
  29.     if (iap_app_sp_check(APP_RUN_START_ADDR) != 0) {
  30.         return ;
  31.     }   

  32.     /* 恢复默认HSI运行并清理环境 */
  33.     if (RCM_ReadSYSCLKFreq() != HSI_VALUE) {
  34.         RCM_EnableHSI();
  35.         while (RCM_ReadStatusFlag(RCM_FLAG_HSIRDY) != SET);
  36.         RCM_ConfigSYSCLK(RCM_SYSCLK_SEL_HSI);
  37.         while (RCM_ReadSYSCLKSource() != RCM_SYSCLK_SEL_HSI);
  38.     }
  39.     app_iap_clear();

  40.     jump_to_run_app = (p_app_func)vec_tab.reset;;

  41.     /* 必须先设置特权模式再设置主堆栈指针,否则无法跳转至 APP */
  42.     __set_CONTROL(0);           /* 在RTOS工程,这条语句很重要,设置为特权级模式,使用MSP指针 */     
  43.     __set_MSP(vec_tab.msp);     /* 设置主堆栈指针 */   
  44.     __ISB();                    /* 强制 CPU 重新取指,确保前面的配置立刻生效 */

  45.     /* 跳转运行APP */
  46.     jump_to_run_app();

  47.     while (1);
  48. }

Boot区的Flash位于MCU的起始位置,即0x08000000,大小为3KB(0xC00),SRAM使用完整的空间即可。
7120669bb49cda1199.png

App程序的main.c开始运行程序时,需重新设置中断向量表地址,因为Boot程序已经将中断向量表改为了Boot区,这里修改为App区,后续产生的中断,都使用App区内的中断服务函数。
  1. /*!
  2. * @brief       Main program
  3. *
  4. * @param       None
  5. *
  6. * @retval      None
  7. *
  8. */
  9. int main(void)
  10. {
  11.     /* 设置中断向量表地址 */
  12.     SCB->VTOR = APP_RUN_START_ADDR;
  13.    
  14.     bsp_init();
  15.     app_init();
  16.    
  17.     while (1) {
  18.         app_task();
  19.     }
  20. }

App程序的后续数据处理,都是基于串口的通讯数据解析。
  1. // 外设初始化
  2. void bsp_init(void)
  3. {
  4.     bsp_uart_init();
  5. }

  6. // 应用初始化
  7. void app_init(void)
  8. {
  9. }

  10. // 应用任务
  11. void app_task(void)
  12. {
  13.     app_comm_parse();
  14. }

此处自定义了一份通讯协议,方便MCU与电脑上位机进行交互,传输数据:
6355869bb49d8ae2d9.png

通讯协议的封装代码,请查阅源码。如下是通讯数据解析:
获取App版本和Boot版本,都是从固定的Flash地址处取数据即可;
升级请求和升级包则调用对应的IAP接口函数进行处理;
特别注意:当IAP数据包发送完成并且处理正确后,MCU需要自动重启已运行最新版的App程序,而在自动重启前,需确保串口数据完成发送,否则上位机接收不到完整的应答数据,不确定MCU的升级状态。
  1. /* 请求升级 */
  2. #define CMD_UPDATE_REQUEST      (0xA1)
  3. /* 升级包 */
  4. #define CMD_UPDATE_PACKET       (0xA2)
  5. /* 获取APP版本 */
  6. #define CMD_APP_VERSION         (0xB1)
  7. /* 获取BOOT版本 */
  8. #define CMD_BOOT_VERSION        (0xB2)

  9. /*
  10. * @brief       通讯解析
  11. *
  12. * @param       None
  13. *
  14. * @retval      None
  15. *
  16. */
  17. void app_comm_parse(void)
  18. {
  19.     uint16_t rx_len = 0;
  20.     uint16_t send_len = 0;
  21.     uint32_t app_size = 0;
  22.     uint32_t data_offset = 0;
  23.     uint32_t version_addr = 0;
  24.    
  25.     /* 检查通讯 */
  26.     if (bsp_uart_is_rx_complete() != 0) {
  27.         rx_len = bsp_uart_get_rx_len();
  28.         bsp_uart_reset_recv();
  29.     }

  30.     /* 解析通讯数据 */
  31.     if ((rx_len != 0) && (comm_pro_parse(&comm_pro, bsp_uart_get_rx_buf(), rx_len) != 0)) {
  32.         switch (comm_pro.cmd) {
  33.             /* 请求升级 */
  34.             case CMD_UPDATE_REQUEST:
  35.                 if (comm_pro.len == (MIN_FRAME_LEN + 4)) {
  36.                     memcpy(&app_size, comm_pro.data, 4);
  37.                     respond_data[0] = (app_iap_request(app_size) == 0) ? 0 : 0xFF;
  38.                     send_len = comm_pro_frame_make(&comm_pro, respond_data, 1, send_buf);
  39.                 }
  40.                 break;
  41.             
  42.             /* 升级包 */
  43.             case CMD_UPDATE_PACKET:
  44.                 if (comm_pro.len > (MIN_FRAME_LEN + 4)) {
  45.                     memcpy(&data_offset, comm_pro.data, 4);
  46.                     respond_data[0] = (app_iap_updating(data_offset, &comm_pro.data[4], (comm_pro.len - (MIN_FRAME_LEN + 4))) >= 0) ? 0 : 0xFF;
  47.                     send_len = comm_pro_frame_make(&comm_pro, respond_data, 1, send_buf);
  48.                 }
  49.                 break;
  50.             
  51.             /* 获取APP版本 */
  52.             case CMD_APP_VERSION:
  53.                 version_addr = APP_RUN_START_ADDR + VERSION_OFFSET + 5;
  54.                 respond_data[0] = (*(uint8_t *)version_addr) - '0';
  55.                 respond_data[1] = (*(uint8_t *)(version_addr + 1)) - '0';
  56.                 respond_data[2] = (*(uint8_t *)(version_addr + 3)) - '0';
  57.                 respond_data[3] = (*(uint8_t *)(version_addr + 4)) - '0';
  58.                 send_len = comm_pro_frame_make(&comm_pro, respond_data, 4, send_buf);
  59.                 break;
  60.             
  61.             /* 获取BOOT版本 */
  62.             case CMD_BOOT_VERSION:
  63.                 version_addr = BOOT_START_ADDR + VERSION_OFFSET + 5;
  64.                 respond_data[0] = (*(uint8_t *)version_addr) - '0';
  65.                 respond_data[1] = (*(uint8_t *)(version_addr + 1)) - '0';
  66.                 respond_data[2] = (*(uint8_t *)(version_addr + 3)) - '0';
  67.                 respond_data[3] = (*(uint8_t *)(version_addr + 4)) - '0';
  68.                 send_len = comm_pro_frame_make(&comm_pro, respond_data, 4, send_buf);
  69.                 break;
  70.             
  71.             default:
  72.                 break;
  73.         }
  74.         
  75.         /* 发送数据 */
  76.         if (send_len != 0) {
  77.             bsp_uart_send(send_buf, send_len);
  78.             /* 最后一包升级数据发送完成,则等到通讯结束重启 */
  79.             if (app_iap_status() == IAP_WAIT_FOR_RESET) {
  80.                 while (bsp_uart_is_tx_complete() == 0);
  81.                 app_iap_reset_to_update();
  82.             }
  83.         }
  84.     }
  85. }

App程序的iap.c文件内,也有一个App版本:
  1. /* 软件版本号 - 位于存储的前第1KB处 */
  2. static const char software_version[10] __attribute__((at(APP_RUN_START_ADDR + VERSION_OFFSET))) = {
  3.     'A', 'P', 'P', '_', '_', '0', '1', '.', '1', '2'
  4. };

App的这个版本定义还有一个好处是,可以使用J-Flash打开编译后的bin文件看得到程序内部的版本信息,文件名可能存在改错名的情况,但bin文件的内容可不好随意乱改。
945969bb49e69cff5.png

申请升级函数,需要检查升级包的大小是否超过APP允许的总大小,没问题则一次性擦除需要的全部Flash空间。
多擦除IAP_APP_OFFSET大小,是因为这段区域要用作程序大小和升级完成标志存储的地方,后面紧接才是真正的bin文件数据。
  1. /*
  2. * @brief       申请升级
  3. *
  4. * @param       app_size: bin文件大小
  5. *
  6. * @retval      0-成功, 其他-失败
  7. *
  8. */
  9. int8_t app_iap_request(uint32_t app_size)
  10. {
  11.     /* 升级包大小不合理 */
  12.     if (app_size > (APP_TOTAL_SIZE - IAP_APP_OFFSET)) {
  13.         return -1;
  14.     }
  15.     /* 擦除Flash: 一次擦完全部所需 */
  16.     if (bsp_flash_erase(APP_IAP_START_ADDR, (app_size + IAP_APP_OFFSET)) < (app_size + IAP_APP_OFFSET)) {
  17.         return -1;
  18.     }

  19.     iap.app_size = app_size;
  20.     iap.app_data_offset = 0;
  21.     iap.iap_status = IAP_UPDATING;
  22.     iap.app_crc = 0;
  23.     return 0;
  24. }

升级包函数,需检查bin文件的数据是连续的,同时不能丢包或超大小;因为申请升级时,已经把需要的所有Flash空间都擦除干净,所以此处只管写入数据即可。
写入的数据,再读取出来,计算循环的CRC_8校验码,bin文件的最后一个字节数据为使用bin文件全部数据计算的CRC_8校验码,当全部bin文件数据都传输并写入Flash程序后,需比对CRC_8是否一致,以确保bin文件数据无误才能使用。
一切都正确后,最后写入升级完成标志字符串和bin文件的总大小,Boot程序需要这两个数据执行App程序的更新流程。
  1. /*
  2. * @brief       升级
  3. *
  4. * @param       data_offset: 数据偏移
  5. *              data: 数据
  6. *              data_len: 数据大小
  7. *
  8. * @retval      0-成功, 其他-失败
  9. *
  10. */
  11. int8_t app_iap_updating(uint32_t data_offset, uint8_t *data, uint16_t data_len)
  12. {
  13.     uint32_t addr = 0;
  14.     uint8_t crc = 0;

  15.     if (iap.iap_status != IAP_UPDATING) {
  16.         return -1;
  17.     }
  18.     /* 检查入参 */
  19.     if ((data == ((void *)0)) || (data_len < 1)) {
  20.         return -1;
  21.     }
  22.     /* 超大小 */
  23.     if ((data_offset + data_len) > iap.app_size) {
  24.         return -2;
  25.     }
  26.     /* 丢包则失败 */
  27.     if (iap.app_data_offset < data_offset) {
  28.         return -3;
  29.     }
  30.     /* 重发不处理 */
  31.     if (iap.app_data_offset > data_offset) {
  32.         return 1;
  33.     }
  34.     /* 写Flash */
  35.     addr = APP_IAP_START_ADDR + IAP_APP_OFFSET + iap.app_data_offset;
  36.     if (bsp_flash_write(addr, data, data_len) < data_len) {
  37.         return -5;
  38.     }
  39.     iap.app_data_offset = iap.app_data_offset + data_len;
  40.     /* 升级完成 */
  41.     if (iap.app_data_offset == iap.app_size) {
  42.         /* 最后1字节为CRC8 */
  43.         iap.app_crc = crc8_calc(iap.app_crc, (uint8_t *)addr, (data_len - 1));
  44.         crc = data[data_len - 1];
  45.         if (iap.app_crc != crc) {
  46.             return -6;
  47.         }
  48.         /* 写升级完成标志 */
  49.         if (bsp_flash_write(APP_IAP_START_ADDR,
  50.                     (uint8_t *)IAP_COMPLETE_FLAG, sizeof(IAP_COMPLETE_FLAG)) < sizeof(IAP_COMPLETE_FLAG)) {
  51.             return -7;
  52.         }
  53.         /* 写数据包大小 */
  54.         if (bsp_flash_write((APP_IAP_START_ADDR + sizeof(IAP_COMPLETE_FLAG)),
  55.                         (uint8_t *)&iap.app_size, 4) < 4) {
  56.             return -8;
  57.         }
  58.         iap.iap_status = IAP_WAIT_FOR_RESET;
  59.         /* 复位升级 */
  60. //        app_iap_reset_to_update();
  61.     } else {
  62.         /* 回读Flash 累计CRC */
  63.         iap.app_crc = crc8_calc(iap.app_crc, (uint8_t *)addr, data_len);
  64.     }

  65.     return 0;
  66. }

当升级包传输完成,并且串口应答上位机的报文也都发送完成后,则需要复位程序。复位程序前依然执行恢复为HSI默认时钟的操作,同时清理代码把当前App程序开启的所有外设都关闭,以恢复一个干净的MCU环境。
注意:执行NVIC_SystemReset软复位前,需屏蔽中断,否则复位过程会有可能被打断而不产生复位。
  1. /*
  2. * @brief       清理环境
  3. *
  4. * @param       None
  5. *
  6. * @retval      None
  7. *
  8. */
  9. static void app_iap_clear(void)
  10. {
  11.     /* 清理的目的是恢复各个外设为初始MCU复位状态,
  12.        防止跳转程序过程中产生中断 */
  13.     /* 当前程序初始化了多少外设,则清理多少外设 */
  14.     bsp_uart_deinit();
  15. }

  16. /*
  17. * @brief       复位重启
  18. *
  19. * @param       None
  20. *
  21. * @retval      None
  22. *
  23. */
  24. void app_iap_reset_to_update(void)
  25. {
  26.     /* 恢复默认HSI时钟 */
  27.     if (RCM_ReadSYSCLKFreq() != HSI_VALUE) {
  28.         RCM_EnableHSI();
  29.         while (RCM_ReadStatusFlag(RCM_FLAG_HSIRDY) != SET);
  30.         RCM_ConfigSYSCLK(RCM_SYSCLK_SEL_HSI);
  31.         while (RCM_ReadSYSCLKSource() != RCM_SYSCLK_SEL_HSI);
  32.     }
  33.     /* 清理环境 */
  34.     app_iap_clear();
  35.     /* 复位前需屏蔽中断,否则复位过程会有可能被打断 */
  36.     __disable_irq();
  37.     /* 复位 */
  38.     NVIC_SystemReset();   
  39. }

App区的Flash从Boot区后开始即0x08000C00,大小为50KB(0xC800),SRAM使用完整的空间即可。
9844169bb49fadbf27.png

同时User界面需要加入生成bin文件指令和在bin文件末尾添加CRC_8校验码的脚本命令:
fromelf.exe --bin --output ..\bin\app.bin !L
..\bin\CRC8_Add.exe ..\bin\app.bin
生成的bin文件放入bin/文件夹内,同时CRC8_Add.exe也在此文件夹内。
6670269bb4a033fe20.png

4. 测试
更改App的版本号,编译2个版本测试:
8985969bb4a08b0a92.png

打开上位机软件进行测试:
2809269bb4a0bb6316.png
999769bb4a12baf6b.png

获取到当前的Boot和App版本如下:
4200669bb4a16c337c.png

选择Bin文件进行升级后,再次读取App版本:
5217369bb4a1ba3770.png
升级程序成功。

5. 移植说明
CRC8_Add和上位机代码均使用QT进行开发,可使用QT重新定制,更改的部分如果涉及MCU端的处理,则也需要同步更改到MCU端。
3153569bc97598d79f.png

6. 详细代码
因单个文件大小限制,需要把opengl32sw文件解压并放到Host_PC文件夹内的realse文件夹内才是完整的上位机运行。
App.zip (8.95 MB) Boot.zip (3.67 MB)
CRC8_Add.zip (14.56 MB)
Host_PC.zip (16.07 MB) opengl32sw.zip (5.66 MB)


打赏榜单

21小跑堂 打赏了 80.00 元 2026-03-31
理由:恭喜通过原创审核!期待您更多的原创作品~~

评论

基于APM32F103的IAP方案展示,通过详细的代码展示和软件设置,实现APM32F103的IAP功能,重点知识覆盖较好,资料全开源,赶紧用起来。 发表于 2026-3-31 14:14
Gfan 发表于 2026-3-26 14:28 | 显示全部楼层
感谢lz分享~~~上手即用呀
goyhuan 发表于 2026-3-27 14:02 | 显示全部楼层
您需要登录后才可以回帖 登录 | 注册

本版积分规则

81

主题

143

帖子

1

粉丝
快速回复 在线客服 返回列表 返回顶部