拓冰建站拓冰建站
首页 / 资讯中心 / 正文

FreeRTOS实战指南:STM32多任务调度与队列通信详解

从裸机循环切换到 RTOS 时很多人会遇到两个典型问题一是任务划分凭感觉二是任务间通信只会用全局变量。回头整理项目实战笔记时我决定把 FreeRTOS 的入门路径、核心机制、可落地项目和常见坑位一次性梳理清楚。本文基于“嵌入式 RTOS 就业级项目”的真实学习场景结合 STM32 平台和 FreeRTOS 常见的调度、队列、信号量、软件定时器带你完整走一遍多任务项目的设计流程每一步都会解释为什么要这样做。无论你是学过 51 单片机想往嵌入式方向进阶的学生还是正在准备 RTOS 面试的开发者这篇文章都适合花时间读完。1. 为什么嵌入式开发者要学 RTOS1.1 从超级大循环到事件驱动嵌入式架构的分水岭很多单片机初学者接触的第一种程序结构是“超级大循环”。也就是在main函数里写一个while(1)按顺序调用各个功能函数while (1) { key_scan(); // 按键扫描 dht11_read(); // 读取温湿度 oled_display(); // 刷新屏幕 uart_send(); // 串口发送 }这种结构在资源极小的 51 单片机项目里很常见逻辑简单写起来也快。但一旦系统功能变多就会出现几个非常难受的问题某个函数执行时间过长会导致其他模块响应延迟。比如按键扫描排在温湿度读取后面如果 DHT11 读取时等待时间太长按键就会“失灵”。所有功能耦合在一个循环里代码越改越乱新增一个功能往往要动很多地方。实时性无法保证。飞机、工业控制、电调这类系统一旦错过中断或响应时序后果可能很严重。于是嵌入式架构从“超级大循环”逐步进化到“事件驱动 多任务”模式。RTOS实时操作系统的核心价值就是让每个功能模块作为独立任务存在由调度器决定谁先运行、运行多久、什么时候阻塞等待。这样代码结构更清晰实时性更容易控制也方便多人协作扩展功能。1.2 FreeRTOS 是什么为什么值得学FreeRTOS 是一个开源的实时操作系统内核专门为单片机等资源受限的嵌入式设备设计。它支持任务调度、消息队列、信号量、互斥锁、软件定时器、内存管理等常用功能而且代码量小、可裁剪性好被大量 MCU 项目和商业产品采用。学习 FreeRTOS 还有一个额外收益它的源码结构简洁清晰非常适合用来理解 RTOS 的核心原理比如任务控制块、就绪列表、调度器切换、临界区保护等。很多嵌入式岗位面试题都会围绕 FreeRTOS 展开例如“任务有哪些状态”“什么是优先级翻转”“中断里能不能调用普通 API”这些内容在 FreeRTOS 里都有非常明确的答案。需要注意的是FreeRTOS 并不等于“所有项目都必须用 RTOS”。对于简单的传感器采集项目裸机前后台系统完全够用。RTOS 更适合任务数量多、实时性要求高、功能需要模块化扩展的场景。选择 RTOS 不是因为技术“高级”而是因为它能降低复杂度、提高可维护性。1.3 适用场景与读者定位本文介绍的项目属于“综合实战型”适合以下读者已经学过 51 单片机或 STM32 裸机开发希望进一步掌握 RTOS。正在准备嵌入式开发岗位面试需要系统梳理 FreeRTOS 核心概念。想从“会跑例程”提升到“能设计一个多任务系统”的开发者。读完本文你可以掌握 FreeRTOS 的核心任务调度原理、常见 API 用法、任务划分方法、队列与信号量通信方式、软件定时器用法以及一套完整的调试和排错思路。2. 环境准备从零搭建 FreeRTOS 开发环境2.1 硬件平台选择FreeRTOS 可以在很多单片机上运行包括多种 51 增强型单片机、STM32、ESP32、GD32 等。不过对于初学者我更推荐使用 STM32F103C8T6 这类入门级 Cortex-M3 芯片作为学习平台原因是资料丰富网上可以找到大量例程。价格便宜核心板成本低。中断控制器NVIC和 FreeRTOS 的配合成熟。可以从 STM32CubeMX 直接生成包含 FreeRTOS 的工程省去手动移植的许多麻烦。如果你手里只有 51 单片机也不是不能学。许多增强型 51 单片机如 STC15、STC8 系列具备足够的 RAM 和 FLASH可以运行裁剪后的 FreeRTOS 或类似 RTOS。但整体而言Cortex-M0/M3/M4 平台对 RTOS 的支持更友好排错也更容易。2.2 软件工具链不同人的开发环境可能不同这里给出常见组合代码编写与编译Keil MDK 或 STM32CubeIDE二选一即可。芯片初始化配置STM32CubeMX用于生成时钟、GPIO、串口、FreeRTOS 基础工程。调试工具ST-Link 或 J-Link用于下载和在线调试。串口工具任意支持串口调试的终端软件用于查看日志输出。版本号需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。不同版本的 CubeMX 生成的代码结构会有细微差异但核心逻辑不变。2.3 获取 FreeRTOS 源码获取 FreeRTOS 源码有三种常见方式使用 STM32CubeMX 内置的 FreeRTOS 中间件这种方式最简单勾选后自动生成移植好的工程。从 FreeRTOS 官方网站或代码托管平台下载源码包手动拷贝到工程中。使用厂商或社区提供的移植例程比如正点原子、野火等开发板资料。这里提醒一点如果选择方式 2 手动移植需要关注FreeRTOSConfig.h这个配置文件里面包含时钟频率、堆大小、任务最大数量、优先级位数等关键配置。配置错误会导致编译失败或运行异常。2.4 工程目录结构建议一个可维护的 FreeRTOS 工程建议按功能模块拆分目录不建议把所有代码都塞进main.c。下面是一个参考结构Project/ ├── Core/ │ ├── Inc/ │ └── Src/ │ ├── main.c │ ├── freertos.c │ └── ... ├── Drivers/ │ ├── BSP/ │ │ ├── dht11.c │ │ ├── oled.c │ │ ├── key.c │ │ └── buzzer.c │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── Middlewares/ │ └── Third_Party/ │ └── FreeRTOS/ └── MDK-ARM/其中Middlewares目录存放 FreeRTOS 源码BSP目录存放每个外设的驱动文件freertos.c存放任务创建和通信对象初始化代码。这样的结构可以让每个文件职责单一后期调试和移植都更高效。3. FreeRTOS 核心原理快速入门3.1 任务、调度器与优先级在 FreeRTOS 中任务Task本质上是一个独立的函数配合一个独立的栈空间。多个任务看起来像是在“并行运行”实际上在单核 MCU 上是由调度器按时间片和优先级轮流切换执行的。每个任务有三个关键属性任务函数任务要执行的逻辑代码。任务栈保存任务上下文寄存器、局部变量的内存区域。优先级决定任务在就绪态时的调度优先级数值越大优先级越高。调度器负责在所有就绪任务中选择“当前应该运行的任务”。当高优先级任务就绪时低优先级任务会被立刻抢占当多个同优先级任务就绪时调度器按时间片轮转调度。在工程实践中优先级设置非常关键。建议把实时性要求高的任务设为较高优先级比如按键响应、关键状态采集把耗时较长但对实时性要求不高的任务设为较低优先级比如日志输出、LCD 刷新。3.2 任务状态与状态切换FreeRTOS 的任务状态主要有四种运行态Running - 当前正在使用 CPU 就绪态Ready - 具备运行条件等待调度器分配 CPU 阻塞态Blocked - 等待某个事件或延时到期 挂起态Suspended - 手动挂起只能通过恢复函数回到就绪态状态转移常用场景高优先级任务抢占正在运行的低优先级任务低优先级任务进入就绪态。任务调用vTaskDelay主动阻塞等待延时结束。任务等待队列数据或信号量数据到来前处于阻塞态。调用vTaskSuspend挂起任务调用vTaskResume恢复任务。理解状态切换是调试 FreeRTOS 的基础。很多时候任务“不运行”不是代码逻辑错误而是任务进入了阻塞态或挂起态只是你没有意识到。3.3 任务创建与删除任务创建使用xTaskCreate函数最典型的创建方式如下#include FreeRTOS.h #include task.h void vTaskExample(void *pvParameters) { for (;;) { // 任务要执行的功能 vTaskDelay(pdMS_TO_TICKS(1000)); } } void vCreateTasks(void) { xTaskCreate(vTaskExample, Example, 128, NULL, 2, NULL); }这里有几个参数需要解释第一个参数是任务函数名。第二个参数是任务名称用于调试显示不参与逻辑。第三个参数是任务栈大小单位是 Word4 字节在 32 位 MCU 上。第四个参数是任务参数可在创建时传入。第五个参数是任务优先级。第六个参数是任务句柄可以用来挂起、恢复或删除任务。xTaskCreate在堆中动态分配任务栈所以要求FreeRTOSConfig.h中配置的configSUPPORT_DYNAMIC_ALLOCATION为 1。如果项目不允许动态内存分配可以使用xTaskCreateStatic配合静态分配的栈和控制块。3.4 常用 API 梳理为了便于快速查阅下面整理了一张 FreeRTOS 常用 API 表分类函数作用任务创建xTaskCreate动态创建任务任务删除vTaskDelete删除任务任务延时vTaskDelay相对延时延时指定 tick 数任务延时vTaskDelayUntil绝对延时适合固定周期执行任务挂起vTaskSuspend挂起任务任务恢复vTaskResume恢复挂起的任务队列创建xQueueCreate创建消息队列队列发送xQueueSend向队列发送数据队列接收xQueueReceive从队列接收数据可设置超时二值信号量xSemaphoreCreateBinary创建二值信号量计数信号量xSemaphoreCreateCounting创建计数信号量互斥锁xSemaphoreCreateMutex创建互斥量软件定时器xTimerCreate创建软件定时器启动调度器vTaskStartScheduler启动 FreeRTOS 调度器4. 就业级实战项目多任务温湿度监测与告警系统4.1 项目需求分析现在进入实战环节。本章以“温湿度监测与告警系统”为例带你完整走一遍多任务项目的设计流程。项目需求如下每 2 秒读取一次 DHT11 温湿度传感器数据。每 500 毫秒刷新一次 OLED 或 LCD 显示屏数据。当温度或湿度超过阈值时蜂鸣器告警并通过串口打印告警信息。按下按键可以开启或关闭告警功能。系统不能因为传感器读取阻塞或显示刷新耗时导致其他任务失去响应。这个项目虽然不算复杂但覆盖了任务划分、队列通信、软件定时器、中断安全等多个 FreeRTOS 核心知识点非常适合作为面试和就业级项目反复练习。4.2 功能拆分与任务划分在编写代码之前首先要把系统拆分成独立的功能模块。根据需求我划分出以下任务任务名职责运行周期优先级建议栈大小SensorTask读取 DHT11 温湿度并通过队列发送2s3256DisplayTask接收温湿度数据刷新显示屏500ms2256KeyTask扫描按键状态切换告警开关50ms4128BuzzerTask接收告警事件控制蜂鸣器事件触发2128AlarmTimer软件定时器用于告警持续时间控制一次性定时无无为什么要这样划分传感器读取容易阻塞放到中等优先级任务中且任务内部使用延时周期触发。显示刷新耗时长但实时性要求不高优先级可以低一些避免影响按键和告警。按键扫描需要较高响应速度因此优先级设为 4每 50ms 扫描一次。蜂鸣器只响应告警事件不需要周期性执行因此采用事件触发方式。这种划分思路是 RTOS 项目设计中最核心的能力决定项目的最终质量。4.3 工程配置与 FreeRTOS 移植如果你使用 STM32CubeMX可以在Middleware中勾选FreeRTOS然后指定调度器版本为 CMSIS_V1 或 CMSIS_V2。这里有一个重要配置项容易踩坑SysTick 和 PendSV 中断优先级需要设置为最低优先级。建议中断优先级分组为全部抢占优先级即优先级分组 4。在FreeRTOSConfig.h中有几个配置项与项目稳定性直接相关#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 8 * 1024 ) ) #define configMINIMAL_STACK_SIZE ( ( unsigned short ) 128 ) #define configUSE_TIMERS 1 #define configTIMER_TASK_PRIORITY 2 #define configTIMER_QUEUE_LENGTH 10 #define configCHECK_FOR_STACK_OVERFLOW 2其中configTOTAL_HEAP_SIZE是 FreeRTOS 管理的内存堆总大小任务栈、队列、信号量都会从这里分配。如果你的项目任务多、队列多需要适当调大这个值。configCHECK_FOR_STACK_OVERFLOW很有用打开后如果任务栈溢出系统会调用vApplicationStackOverflowHook回调便于快速定位问题。4.4 编写核心任务代码4.4.1 创建通信队列任务之间不能直接调用对方函数最常用的通信方式是队列。先定义温湿度数据结构和队列句柄#include FreeRTOS.h #include task.h #include queue.h typedef struct { float temperature; float humidity; } EnvData_t; QueueHandle_t xEnvQueue; QueueHandle_t xAlarmQueue;然后在初始化函数中创建队列void vCreateQueues(void) { xEnvQueue xQueueCreate(2, sizeof(EnvData_t)); xAlarmQueue xQueueCreate(4, sizeof(uint8_t)); }xQueueCreate第一个参数是队列可存放的元素数量第二个参数是单个元素大小。队列长度要根据生产者和消费者的速率差异合理配置太短会造成数据丢失太长会浪费 RAM。4.4.2 温湿度采集任务采集任务的伪代码如下void SensorTask(void *pvParameters) { EnvData_t envData; TickType_t xLastWakeTime xTaskGetTickCount(); for (;;) { // 从 DHT11 读取温度湿度 // 这里需要调用工程内的 DHT11 驱动不同开发板引脚不同 envData.temperature readTemperature(); envData.humidity readHumidity(); // 将数据发送给显示任务 xQueueSend(xEnvQueue, envData, 0); // 判断是否超过阈值超过则发送告警事件 if (envData.temperature 35.0f || envData.humidity 80.0f) { uint8_t alarmEvent 1; xQueueSend(xAlarmQueue, alarmEvent, 0); } // 固定周期执行 vTaskDelayUntil(xLastWakeTime, pdMS_TO_TICKS(2000)); } }这里特别说明一下vTaskDelayUntil和vTaskDelay的区别vTaskDelay是相对延时从调用时刻开始计时实际周期会包含任务执行本身的时间。vTaskDelayUntil是绝对延时它根据上一次唤醒时间计算下一次唤醒时间误差更小适合固定周期采集。4.4.3 显示刷新任务显示任务等待队列中的数据然后刷新 OLED 屏幕。void DisplayTask(void *pvParameters) { EnvData_t envData; for (;;) { if (xQueueReceive(xEnvQueue, envData, pdMS_TO_TICKS(1000)) pdPASS) { // 刷新 OLED 显示屏 // OLED_ShowString(0, 0, Temp:); // OLED_ShowNum(0, 6, envData.temperature); } vTaskDelay(pdMS_TO_TICKS(500)); } }显示任务每 500ms 刷新一次屏幕但数据只有在队列收到新数据时才会更新。这样保证显示内容始终是最新的同时屏幕刷新不会拖慢传感器采集。4.5 软件定时器与告警逻辑除了任务之外FreeRTOS 还提供软件定时器。这个项目里蜂鸣器不能一直响需要有一个持续时间控制。可以创建一个一次性软件定时器到达时间后自动关闭蜂鸣器。TimerHandle_t xBuzzerTimer; void BuzzerTimerCallback(TimerHandle_t xTimer) { // 关闭蜂鸣器 // Buzzer_Off(); } void vCreateTimer(void) { xBuzzerTimer xTimerCreate(BuzzerTimer, pdMS_TO_TICKS(3000), pdFALSE, NULL, BuzzerTimerCallback); }告警任务收到队列消息后开启蜂鸣器同时启动这个一次性定时器void BuzzerTask(void *pvParameters) { uint8_t alarmEvent; for (;;) { if (xQueueReceive(xAlarmQueue, alarmEvent, portMAX_DELAY) pdPASS) { // 打开蜂鸣器 // Buzzer_On(); // 启动或重置 3 秒定时器 xTimerStart(xBuzzerTimer, 0); } } }这种设计的好处是告警逻辑被独立出来不会阻塞其他任务定时器关闭蜂鸣器避免告警一直响即使持续收到告警事件也可以通过xTimerStart反复重置定时器实现“只要温度持续超标蜂鸣器就保持响”。4.6 中断安全在中断中与任务通信实际项目中按键和外部信号往往通过中断触发。FreeRTOS 规定中断服务函数中不能调用普通任务的 API必须使用带FromISR后缀的版本。以按键外部中断为例中断服务函数中可以向任务发送事件通知void EXTI0_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; // 清除中断标志 // EXTI_ClearITPendingBit(EXTI_Line0); // 向按键任务发送通知 vTaskNotifyGiveFromISR(xKeyTaskHandle, xHigherPriorityTaskWoken); // 如果唤醒的高优先级任务需要立刻运行则切换上下文 portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }如果只是给任务发通知使用xTaskNotifyGive系列是很高效的方式。如果需要传达数据则使用xQueueSendFromISR。中断安全这一块是嵌入式面试的重点也是工程事故高发区必须重视。4.7 运行与验证完成以上代码后编译下载到开发板预期运行现象如下屏幕每 500ms 刷新温湿度数值每 2 秒变化一次。手握住传感器提高温度超过阈值后蜂鸣器响 3 秒后自动停止。按键可以随时关闭或开启告警功能。串口调试助手输出温度变化日志。如果发现传感器任务阻塞导致显示卡顿先检查任务划分和优先级是否合理再检查队列长度是否足够。如果系统直接卡死优先检查configTOTAL_HEAP_SIZE是否偏小。5. 常见问题与排查思路5.1 任务没有运行或系统卡死这是新手最容易遇到的问题。可能原因有多种忘记调用vTaskStartScheduler()调度器没有启动。某个任务栈分配过小导致栈溢出系统进入 HardFault。某个高优先级任务内部有死循环没有阻塞或延时导致低优先级任务永远得不到 CPU。中断优先级配置错误SysTick 或 PendSV 被更高优先级中断长期抢占。堆内存不足任务创建失败。排查顺序建议是先确认vTaskStartScheduler被调用再检查堆大小和任务栈大小最后用调试器查看任务状态。5.2 堆栈溢出检测FreeRTOS 支持开启堆栈溢出检测。在FreeRTOSConfig.h中设置#define configCHECK_FOR_STACK_OVERFLOW 2然后实现回调函数void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { // 在这里记录错误日志或者点亮错误指示灯 }当configCHECK_FOR_STACK_OVERFLOW为 2 时会检测更多溢出场景但也会增加运行开销。一般在调试阶段开启正式发布前可以根据情况关闭。5.3 优先级翻转与互斥量优先级翻转是 RTOS 的经典问题。简单来说高优先级任务在等待低优先级任务占用的资源时可能被中优先级任务抢占导致最需要 CPU 的任务迟迟得不到执行。FreeRTOS 中的互斥量Mutex支持优先级继承机制可以在一定程度上解决优先级翻转问题。创建和使用互斥量的示例SemaphoreHandle_t xMutex; xMutex xSemaphoreCreateMutex(); void vWriteSharedData(void) { if (xSemaphoreTake(xMutex, portMAX_DELAY) pdPASS) { // 进入临界区修改共享数据 xSemaphoreGive(xMutex); } }注意互斥量不能在中断中使用中断中只能使用二值信号量或队列。5.4 常见问题排查表问题现象常见原因解决思路系统启动后没有任何任务运行未调用vTaskStartScheduler检查启动流程任务创建失败堆内存不足增大configTOTAL_HEAP_SIZE运行一段时间后进入 HardFault任务栈溢出开启堆栈溢出检测增大栈大小低优先级任务长时间不执行高优先级任务不阻塞在高优先级任务中添加延时或等待事件中断中调用任务 API 导致死机中断上下文不能使用普通 API改用带FromISR后缀的 API数据接收不完整队列长度不够增加队列长度或调整发送频率6. 最佳实践与工程建议6.1 任务划分原则任务划分是 RTOS 项目设计中最需要经验的环节。任务划分过细会导致频繁切换浪费 CPU任务划分过粗会导致实时性不满足。可以参考以下原则把有实时性要求的功能单独成任务比如按键响应、告警输出。把周期明确的功能按固定周期任务设计比如传感器采集。把事件驱动的功能设计为等待队列或信号量比如串口数据接收。避免在任务内部做长时间阻塞的 IO 操作比如延迟等待传感器响应时使用状态机或超时机制。6.2 内存管理策略FreeRTOS 提供了多种内存分配实现常见的包括 heap_1、heap_2、heap_3、heap_4、heap_5。不同实现有不同特点heap_1只能分配不能释放适合任务创建后不再删除的项目。heap_2支持释放但不会合并相邻空闲块容易产生碎片。heap_4支持合并相邻空闲块推荐大多数项目使用。heap_5支持跨多个不连续内存空间分配适合复杂内存布局。在任务中频繁创建和删除对象会增加内存碎片风险稳定系统中尽量在初始化阶段完成所有动态创建。6.3 调试与日志开发期间建议使用串口打印关键事件比如任务启动、错误发生、队列满等。如果 RAM 足够可以引入vTaskList或uxTaskGetSystemState获取任务状态信息方便观察任务是否处于预期状态。需要注意的是vTaskList输出任务状态时需要开启configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS。输出信息一般通过串口传输会占用一定时间建议只在调试阶段使用。6.4 嵌入式面试高频考点如果你正准备嵌入式岗位面试以下几个 FreeRTOS 相关问题出现频率很高任务有哪几种状态状态如何切换抢占式调度和时间片轮转有什么区别什么是优先级翻转如何解决二值信号量、计数信号量、互斥量有什么区别中断服务函数中能调用哪些函数什么是临界区如何保护共享资源FreeRTOS 的 Tick 是什么影响实时性的因素有哪些这些问题的答案都能从本文第 3 节和第 5 节找到但面试时还要学会结合实际项目讲清楚比如“我在温湿度采集项目中遇到了队列满的问题通过增大队列长度和调整生产消费速率解决”。6.5 生产环境注意事项如果项目要进入量产阶段除了功能调试还需要注意以下问题所有涉及外设读写、共享资源访问的操作必须有明确的访问权限和临界区保护。生产环境变更前必须备份原始代码和参数配置并在测试环境充分验证。建议接入看门狗防止任务跑飞或死循环导致系统长时间无响应。不要在生产固件中保留大量调试日志避免影响性能。增加掉电保存和异常上报机制方便现场问题定位。硬件相关操作要谨慎特别是在没有充分测试的情况下不要直接修改寄存器配置。最小权限原则在这里同样重要只开放必要的模块和接口避免无关代码影响系统稳定性。7. 总结与学习路线7.1 本文核心收获通过这篇项目实战你应该掌握了 FreeRTOS 的以下关键知识点从超级大循环到多任务架构的升级思路。FreeRTOS 任务状态、优先级、调度原理。任务创建、队列通信、软件定时器、中断安全 API 的用法。如何基于 STM32 和 FreeRTOS 搭建一个完整的温湿度监测与告警系统。遇到任务不运行、堆栈溢出、优先级翻转等问题时的排查方法。嵌入式岗位面试中常见的 RTOS 高频考点。7.2 下一步学习建议学完本文后建议你在开发板上独立重写一遍这个项目然后尝试以下方向继续拓展在项目中增加串口协议解析任务比如对接 Modbus 协议加深对队列和 DMA 的理解。阅读 FreeRTOS 内核源码中的任务调度器实现比如vTaskSwitchContext和prvStartFirstTask。在 ESP32 或 STM32 上尝试使用更完整的 RTOS 或嵌入式 Linux对比不同系统的应用场景。动手设计一个包含多个传感器、多个通信接口、多个状态机的综合项目把任务划分能力真正练出来。7.3 给初学者的实践建议不要只看不练。FreeRTOS 的难点不在 API 语法而在于任务并发和资源竞争。建议你准备一块百余元的 STM32 核心板把本文的例子完整跑起来再尝试修改优先级、删除某个队列、加入互斥量观察现象变化。踩过几个坑之后你对 RTOS 的理解会明显上一个大台阶。如果本文对你有帮助可以先收藏备用。下一篇我准备继续写 FreeRTOS 内核源码分析或队列机制详解欢迎在评论区聊聊你在学习 FreeRTOS 过程中遇到的最棘手的问题。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门