简介:面向嵌入式开发者的ThreadX串口通信工程包,专注STM32平台在ThreadX实时操作系统下用DMA完成UART收发,适合已有裸机串口基础、准备迁移到RTOS并想弄清队列与信号量协同调度的中级开发者。包内约2000个文件,以905个C源文件和345个头文件为主体,另有启动汇编、分散加载与链接脚本、工程配置、静态库及Cortex-M系列数学库等配套文件,压缩包约15.36MB,用STM32CubeMX和Keil打开即可复现实验。工程示范了中断与DMA模式下的数据搬运、用消息队列传递接收数据、以信号量同步收发流程,可作为产品级串口框架的参考模板。目前已有275人学习下载,便于对照源码理解内核对象的使用边界与常见调试思路。
1. 从一份 UART_ThreadX.7z 说起:为什么 CubeMX 里的 ThreadX 一装就翻车
解压一份带 RTOS 的 STM32CubeMX 工程,双击 .ioc 打开,最常见的结果是弹窗提示找不到中间件;就算打开了,点 Generate Code 之后编译器也能甩出几十行undefined symbol _tx_initialize_low_level。这类失败跟业务代码没什么关系,问题基本都出在三个地方:X-CUBE-AZRTOS 扩展包没装全、Toolchain 选的和 ports 汇编对不上、SysTick 被 ThreadX 拿走之后 HAL 的时基没人接。ThreadX 在 STM32CubeMX 里不是拷几个 .c 进来就能跑的东西,它是一整套扩展包加内核汇编端口再加时基重定向。这份 UART_ThreadX 工程留下的就是一份已经把这些坑填平的参照:串口收发跑在线程里、打印不阻塞主循环、两套时基各归各位。适合手上已经有 Cortex-M 板子、想用 ThreadX 做串口队列收发的人,从零照着搭一遍最省事。
2. 版本对齐:STM32CubeMX 6.x、X-CUBE-AZRTOS 与 ThreadX 内核的三方搭配
装 ThreadX 这事,第一件要做的不是点安装,而是把三个东西的版本关系捋清楚:CubeMX 本身、对应芯片系列的固件包、以及 X-CUBE-AZRTOS 扩展包。这三者任意一个超前或落后,症状都不一样,而且都不会直接告诉你"版本不匹配",只会用编译错误或者灰掉的选项让你自己猜。
2.1 三个组件各自的职责与不匹配时的症状
CubeMX 负责生成工程骨架和中间件配置,固件包提供 HAL 和 CMSIS 那套头文件,X-CUBE-AZRTOS 则把 ThreadX 内核源码、端口汇编、以及 CubeMX 的配置界面一起带进来。我一般会先对照下表把本地环境摸一遍,再决定装哪一版。
| 组件 | 作用 | 在哪确认 | 不匹配时的典型症状 |
|---|---|---|---|
| STM32CubeMX 6.x | 生成工程、配置中间件 | Help → About | .ioc 打不开,或中间件分类整块灰掉 |
| STM32Cube FW_xx 固件包 | HAL 驱动、CMSIS、启动文件 | Manage embedded software packages | 编译报 HAL 宏重复定义、外设句柄类型对不上 |
| X-CUBE-AZRTOS-xx | ThreadX 内核 + ports + 配置界面 | 同上,厂商分类下 | Middleware 里找不到 ThreadX,或链接报汇编符号缺失 |
| Toolchain/IDE | 决定用哪一份 ports 汇编 | Project Manager → Toolchain/IDE | .s 文件报语法错,_tx_initialize_low_level 找不到 |
版本策略上我一向保守:不追最新,选一个本地包管理器里能同时看到、并且和你的固件包同期的组合,然后整份工程锁死。中途升级扩展包是血泪经验——CubeMX 允许同一个扩展包多版本共存,升级之后旧工程默认指向的版本号可能悄悄变了,接口没变但端口文件换了,编译能过、跑起来节拍不对,这种问题最难查。
一份工程到手,先解压再说。压缩包里的路径结构本身就是要检查的第一项:
# 先看清单,确认 Middlewares 和 Drivers 都在,不是只打了个 Core 目录 7z l UART_ThreadX.7z | head -40 # 解压到纯英文、无空格的目录,CubeMX 对中文路径和长路径的兼容一直不稳 7z x UART_ThreadX.7z -o./uart_threadx第一条命令只是列清单,不做任何写入,用它判断这份工程是不是完整工程——正常的 CubeMX 工程至少要有.ioc、Core/、Drivers/、Middlewares/四块,缺任何一块都说明打包时漏了,别急着往下走。第二条才是真正解压,输出目录特意避开桌面和中文路径,原因是 CubeMX 生成代码时会把路径拼进 Makefile 和工程文件里,带空格或中文的路径在链接阶段容易出莫名其妙的路径截断。
2.2 扩展包的两种装法:在线安装与本地导入
在线安装是首选。打开 Help → Manage Embedded Software Packages,展开厂商分类,找到对应芯片系列的 X-CUBE-AZRTOS 包,点 Install,等进度条走完。装完建议重启一次 CubeMX,因为中间件列表是启动时扫描的,不重启经常看不到新装的 ThreadX。
离线导入用在没网或者版本被下架的情况。同一个界面里有个 From Local 的入口,选中扩展包 zip 即可。这里有个必须注意的点:
注意:本地导入的 zip 不要事先手动解压再指过去,CubeMX 要的是原始压缩包。已经解压过的目录它认不出来,会一直提示包格式错误。
装完还没完,回到工程里检查扩展包是否真的生效。一个可靠的判断方法是看.ioc文件里有没有对应的中间件条目:
# 打开 .ioc,搜中间件名,搜得到才说明这份工程和当前装的包对得上 grep -i "threadx\|azrtos" uart_threadx/*.ioc | head -20grep匹配的是.ioc这种纯文本配置文件,里面记录了 MxCube 版本、器件型号、以及每个中间件的使能状态。如果什么都没搜到,说明这份工程生成时压根没勾 ThreadX,或者勾了但用的是另一套包名,那就别在代码里找了,问题在配置层。
2.3 解压后先看四个地方,判断能不能直接生成
我把每次拿到陌生工程的开场检查固定成四步,按顺序看,任何一步不对就停在那里先解决:
.ioc头部的 MxCube.Version 和 ProjectManager.DeviceId,确认版本和芯片型号跟你本地装的对得上。Middlewares/Third_Party/ThreadX/目录是否存在,里面应该有common/和ports/。Middlewares/Third_Party/ThreadX/ports/cortex_m4/下有没有你当前工具链对应的子目录。Core/Src/tx_initialize_low_level.S(GCC)或.s(MDK/IAR)是否存在,这个文件是内核启动的汇编端口。
第三步最容易被忽略。ThreadX 的上下文切换、节拍中断入口全写在这份汇编里,不同工具链的语法完全不一样。GCC 用.S大写后缀走预处理器,MDK 用 armasm 语法,两份文件不通用。工程里只有一份、而你用的又是另一种工具链,链接阶段必然报符号缺失。补救办法很简单:在 CubeMX 里把 Toolchain/IDE 切到匹配的那个,重新 Generate Code,端口文件会按需重新生成。
3. 内核配置:在 STM32CubeMX 里把 ThreadX 的时基和线程跑起来
扩展包装好、工程能打开,接下来才是真正动配置。这一章的三件事按顺序做:先抢时基、再使能内核、最后建线程。顺序颠倒的话,生成出来的代码能编译但跑不起来,而且现象很迷惑。
3.1 时基重定向:SysTick 交给 ThreadX,HAL 换一个基本定时器
这是整个安装过程中最关键的一步,也是最多人跳过的一步。ThreadX 的 Cortex-M 端口把 SysTick 当作系统节拍源,PendSV 做上下文切换。HAL 库的HAL_Delay和HAL_GetTick同样依赖 SysTick 中断里累加的uwTick。两者抢同一个中断,后果分两种:轻则HAL_Delay短延时看着正常、超过几百毫秒就永远不出来;重则 ThreadX 节拍不稳,tx_thread_sleep睡 100 tick 实际睡了两倍。
操作路径是 System Core → SYS → Timebase Source,从 SysTick 改成 TIM6(任意一个没被占用的基本定时器都行)。改完 CubeMX 会自动生成一个独立的时基文件:
/* stm32xxxx_hal_timebase_tim.c,CubeMX 在时基切到 TIM 后自动生成 * 关键点:中断里只做 uwTick 累加,HAL_Delay/HAL_GetTick 由此恢复可用 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { HAL_IncTick(); /* 1ms 一次的 HAL 节拍,与 ThreadX 的 SysTick 完全隔离 */ } }这段回调的触发周期由 TIM6 的预分频和重装载值决定,CubeMX 会自动按 1ms 算好,不需要手改。真正要盯的是 TIM6 的中断优先级——我一般直接给到最低(数值最大那档),原因是它只做一件事:累加一个计数。优先级给高了,反而可能在 ThreadX 做上下文切换的关键窗口里插进来,虽然多数时候不会立刻出事,但出的时候一定查不出来。生成代码后记得确认MX_TIM6_Init()在main()里的调用位置在MX_ThreadX_Init()之前,顺序反了的话,ThreadX 起来的时候HAL_GetTick还返回 0。
3.2 中间件里勾选 ThreadX 并调内核参数
Middleware and Software Packs → Azure RTOS ThreadX → 勾选使能,下面会展开一页参数。需要动的没几个,但每个都有讲究:
| 参数 | 常见取值 | 说明 |
|---|---|---|
| TX_TIMER_TICKS_PER_SECOND | 1000 | 每秒节拍数,与上面 1ms 的 HAL 时基对齐最省心 |
| TX_MAX_PRIORITIES | 32 | 优先级数量,0 最高,数值越大优先级越低 |
| Memory Pool Size | 按线程数给 | 线程控制块、队列、信号量都从这里分配 |
| TX_ENABLE_STACK_CHECKING | 开启 | 线程栈被踩穿时能回调报错,而不是直接 HardFault |
| TX_DISABLE_PREEMPTION | 关闭 | 除非确定要协作式调度,否则别开 |
TX_TIMER_TICKS_PER_SECOND和 HAL 那边保持一致不是为了好看。两者都按 1ms 走,你才能直接拿tx_time_get()和HAL_GetTick()对比来验证——后面验证章节会用上这一招。内存池大小是最容易给小的,一份工程里如果建了五六个线程、再加两个队列,池子给到几 KB 才算稳,给不够的表现是tx_thread_create返回TX_POOL_ERROR,而这个返回值很多人根本没接。
设置完点 Generate Code,CubeMX 会生成Core/Src/app_threadx.c和Core/Inc/tx_user.h。前者是留给你填线程的空壳,里面只有一个空的tx_application_define,后者是内核裁剪开关。这两个文件生成之后就不要再用 CubeMX 覆盖了——重新 Generate 会按配置重写,手写的东西会丢,我一般在第一次生成后立刻把它们纳入版本管理。
3.3 建线程:栈大小和优先级的单位别照抄 FreeRTOS
从 FreeRTOS 迁过来的人在这一步几乎必翻车,因为tx_thread_create的栈参数单位是字节,而xTaskCreate的栈深度单位是字。同样写 128,一个给 128 字节、一个给 512 字节。
/* Core/Src/app_threadx.c —— CubeMX 生成后是个空壳,线程要自己往里加 */ #include "tx_api.h" #include "app_threadx.h" TX_THREAD uart_thread; TX_QUEUE uart_rx_queue; /* 栈必须静态分配或全局,别写在函数里:函数返回后栈就被回收了 */ static ULONG uart_thread_stack[1024 / sizeof(ULONG)]; VOID tx_application_define(VOID *first_unused_memory) { /* 队列的存储区由调用方提供,大小 = 消息个数 × 单条消息字节数 */ static ULONG queue_buf[16 * sizeof(uint8_t) / sizeof(ULONG)]; tx_queue_create(&uart_rx_queue, "uart_rx", 1, queue_buf, sizeof(queue_buf)); /* 1024 是字节数,不是字数;优先级 10,数字越小越优先 */ tx_thread_create(&uart_thread, "uart_thread", uart_thread_entry, 0, uart_thread_stack, sizeof(uart_thread_stack), 10, 10, TX_AUTO_START); }逐个参数说清楚:第一个是线程控制块指针,全局变量,由内核填。第二个是名字,调试器里能看到,别省。第三个是入口函数,签名固定void f(ULONG)。第四个是传给入口的参数。第五、六个是栈起始地址和字节数。第七、八个是优先级和抢占阈值,两者给相同值表示标准抢占式调度。最后一个是启动方式,TX_AUTO_START会在内核启动后自动进入就绪态。
队列那条tx_queue_create的第三个参数是单条消息的字节数(这里 1 字节,适合串口这种逐字节流),第四个是存储区,第五个是存储区总字节数。存储区必须是ULONG对齐的静态区,用uint8_t数组在某些编译器的严格对齐检查下会出问题,所以我习惯统一声明成ULONG数组再按字节算长度。
4. 串口落地:USART1 参数、printf 重定向与线程收发闭环
内核跑起来只是有了骨架,UART 才是这套工程真正被用起来的地方。这一章要解决三件事:串口硬件参数配对、打印输出不拖慢线程、以及中断收数据到线程处理这条链路打通。
4.1 USART 参数、引脚与中断的分配原则
Connectivity → USART1 → Mode 选 Asynchronous,剩下的按下面这张表填:
| 配置项 | 取值 | 为什么这么给 |
|---|---|---|
| Baud Rate | 115200 | 通用速率,调试输出够用且不易因时钟误差丢包 |
| Word Length | 8 Bits | 标准 8N1,绝大多数串口工具默认 |
| Parity | None | 同上,开启会增加一字节开销 |
| Stop Bits | 1 | 同上 |
| Over Sampling | 16 | 采样点数多,抗时钟误差能力强于 8 |
| USART1 global interrupt | Enabled | 接收走中断,否则线程要轮询浪费 CPU |
引脚按板子来,CubeMX 会自动分配 PA9/PA10 或对应的复用脚。一个经常被忽略的点是 USART1 的时钟源——部分系列的串口挂在 APB2 上,而 APB2 的分频跟主频不一定一致,时钟树配错、波特率算出来就会偏,现象是能发出数据但对方全是乱码。稳妥做法是配完时钟后,在 CubeMX 的时钟树界面确认一下 USART1 的输入时钟值,再手算一遍分频:USARTDIV = fCK / (16 × Baud)。
中断优先级这块给个经验值:串口中断设到中等(5 左右),不要设成 0。原因不是 ThreadX 的 API 不能在 ISR 里调,而是优先级最高意味着它可能在内核做上下文切换的窗口里插进来,此时栈指针状态未稳定,ISR 里再调内核服务就有概率翻车。这个坑不常触发,但触发一次就够查半天。
4.2 printf 重定向到串口:三种做法与各自的代价
让printf打到串口,是这套工程最实用的一个功能,也是栈溢出的重灾区。工具链不同,重定向的入口函数也不同:
/* 做法一:Keil/MDK 工具链,重定向 fputc,一个字符一个字符阻塞发 */ int fputc(int ch, FILE *f) { /* 超时给足,HAL_UART_Transmit 超时返回后字符就丢了,还查不出来 */ HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }/* 做法二:GCC 工具链(CubeIDE / Makefile),重定向 _write,支持整块输出 */ int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }两种做法的差别不只是函数名。fputc是逐字节写,每次调用都进一次 HAL,一秒钟打几百行日志就直接把线程堵死;_write是整块写,printf攒够一个缓冲区才调一次,效率高得多。但整块写有个隐患:缓冲区在栈上,长度随格式化字符串变化,栈给小了会直接踩穿。
第三种做法是 DMA 加重定向,把HAL_UART_Transmit_DMA塞进_write里,线程立即返回。代价是要自己处理"上一帧还没发完又来一帧"的冲突,我一般配一个TX_SEMAPHORE,发送完成回调里tx_semaphore_put,下次发送前先tx_semaphore_get等上一帧结束。串口日志量大的项目值得这么干,量小的项目用阻塞版更省心。
提示:开了 printf 的线程,栈至少给 1KB。浮点格式化、长字符串拼接实际占用的栈比想象中大得多,给 512 字节然后打印一句带小数点的日志,十次里有三次会挂。
4.3 中断收、线程发的队列闭环
串口收数据最忌讳在中断里做协议解析。正确的分工是:中断只负责把字节搬进队列,解析和回显全放在线程里做。这样即使解析逻辑很重,也不会阻塞其他中断。
/* 接收中断回调:只做搬运,不做解析,中断里绝不能用等待型 API */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { static uint8_t rx_byte; if (huart->Instance == USART1) { /* TX_NO_WAIT:队列满就丢弃并返回错误,中断上下文不能等 */ tx_queue_send(&uart_rx_queue, &rx_byte, TX_NO_WAIT); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 重新挂接收,别忘了这句 */ } }/* 线程侧:阻塞等队列,收到一个字节就回显一个,逻辑再重也不影响中断 */ static void uart_thread_entry(ULONG arg) { uint8_t b; for (;;) { if (tx_queue_receive(&uart_rx_queue, &b, TX_WAIT_FOREVER) == TX_SUCCESS) { HAL_UART_Transmit(&huart1, &b, 1, 100); } } }回调里的rx_byte必须是静态的或者全局的,写成局部变量的话,HAL_UART_Receive_IT拿到的地址在函数返回后就失效了,DMA 或中断写进去的是野指针。tx_queue_send用TX_NO_WAIT是硬性要求,中断上下文里用TX_WAIT_FOREVER会让整个系统挂在这里等一个永远不会来的唤醒。
线程侧用TX_WAIT_FOREVER是合理的,因为这个线程本来就是被设计成"有数据才干活"的消费者,没数据时挂起不占 CPU。tx_queue_receive的第三个参数是等待 tick 数,TX_WAIT_FOREVER意味着无限等,队列一来数据立刻唤醒,唤醒延迟通常在几个微秒量级。
5. 排查清单:ThreadX 加 UART 组合最常见的五个翻车现场
装完之后出问题,八成集中在这五条上。我把它们按出现频率排了序,每条都按现象、原因、解决三段写,对着看比翻文档快。
5.1 编译期:符号缺失和头文件找不到
现象一:链接报undefined symbol _tx_initialize_low_level,或者 .s 文件一串语法错误。
原因是工程里的 ports 汇编和当前工具链对不上。X-CUBE-AZRTOS 扩展包内置了好几套端口,CubeMX 只会把匹配当前 Toolchain 设置的那一套拷进工程。如果你换了 IDE 但没在 Project Manager 里改 Toolchain/IDE,工程里留着的还是旧工具链的汇编,链接器自然找不到入口。解决办法是回 CubeMX 把 Toolchain/IDE 改成实际使用的那个,重新 Generate Code。改完去Middlewares/Third_Party/ThreadX/ports/cortex_m4/下面确认对应工具链目录存在,别只信生成日志。
现象二:编译报tx_api.h: No such file or directory。
扩展包装了,但工程的 include 路径没带上。CubeMX 生成时会把中间件路径写进工程的包含目录,没写进去通常是因为.ioc里 ThreadX 的使能状态和实际装的包版本不匹配。检查方式是在 CubeMX 里重新勾选一次 ThreadX,看有没有弹出"版本不兼容"的提示;没有提示就重新 Generate,有提示就说明得换扩展包版本。手工往 Makefile 或工程属性里加路径也能编过,但下次 Generate 会被覆盖,不建议这么绕。
5.2 运行期:卡死、乱码和只发一帧
现象三:程序烧进去之后卡在HAL_Delay里不出来,调试器暂停发现一直在这个函数里循环。
这是最经典的一条:SysTick 被 ThreadX 接管了,HAL 时基还指着 SysTick。表现是HAL_GetTick()返回值不再增长,所有基于它的超时判断全部失效。解决就是 3.1 节那一步,System Core → SYS → Timebase Source 改成 TIM6,重新生成。改完记得在main()里确认 TIM6 的初始化在 ThreadX 初始化之前。
现象四:串口能收到数据,但全是乱码,或者只有开头几个字符正常。
两个方向查。先查时钟:外部晶振实际频率和 CubeMX 里填的不一致,波特率算出来就偏。用逻辑分析仪量一下实际波特率最直接,没有仪器就用示波器看单个位的宽度,115200 下一位约 8.68 微秒。再查重定向:HAL_UART_Transmit返回了但最后一个字节还没从移位寄存器发完,程序就进了低功耗或者切了时钟,末尾字符就被截断。稳妥做法是发送后等 TC 标志:
/* 等发送完成标志,避免最后一个字节还在移位寄存器里就被后续操作打断 */ HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100); while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET) { }现象五:DMA 发送只发出第一帧,后面再调就没反应了。
大概率是发送缓冲区写成了函数内的局部数组。HAL_UART_Transmit_DMA是异步的,函数返回时数据还没发完,如果缓冲区在栈上,函数一返回这块内存就被下一层调用复用了,DMA 搬出去的是一堆垃圾。改成static或者全局数组即可。另一个可能是连续调用太快,上一帧的 DMA 传输完成标志没清,HAL 直接返回HAL_BUSY。用信号量串起来最稳:发送前tx_semaphore_get,HAL_UART_TxCpltCallback里tx_semaphore_put。
6. 跑通验证:三个动作确认 ThreadX 节拍与 UART 收发都在正常工作
代码编过、烧进去、串口有输出,这只是"看起来能跑"。真正要确认的是两套时基都在走、线程确实在调度、串口链路在高负载下不丢数据。我固定用三个动作验证,缺一个都不算装完。
6.1 第一个动作:对比两个 tick,确认时基没打架
在任意一个线程里加一行打印,把两个时间源摆在一起看:
/* 放在监控线程里,每 5 秒打一行;两个值必须同步增长 */ tx_thread_sleep(5000); /* 5000 tick = 5 秒,前提是 TX_TIMER_TICKS_PER_SECOND=1000 */ printf("hal_tick=%lu tx_tick=%lu\r\n", (unsigned long)HAL_GetTick(), (unsigned long)tx_time_get());判读方法很直接:hal_tick不动,说明 TIM6 时基没生效,回去查 SYS 配置和HAL_TIM_PeriodElapsedCallback;tx_tick不动,说明 ThreadX 节拍没起来,查tx_initialize_low_level里的 SysTick 配置。两个都在涨但差值越来越大,说明有一路被频繁打断丢 tick,通常是某个中断优先级给得太高。
6.2 第二个动作:栈水位与溢出回调
线程栈给多少合适,靠猜没用。开启TX_ENABLE_STACK_CHECKING之后注册一个错误回调,栈被踩穿时能停在现场而不是直接进 HardFault:
/* 在 tx_application_define 里注册;栈溢出时会被调用,此时线程名就是嫌疑人 */ VOID stack_error_handler(TX_THREAD *thread_ptr) { printf("[stack] overflow in %s\r\n", thread_ptr->tx_thread_name); for (;;) { } /* 停在这里,挂调试器看调用栈比事后推理快得多 */ } /* 注册语句放在线程创建之前 */ tx_thread_stack_error_notify(stack_error_handler);这个回调的价值在于把"随机 HardFault"变成"带线程名的确定性报错"。跑了几天才挂一次的问题,靠这个能一次定位。
6.3 第三个动作:连续灌数据十分钟
最后一步是压测。用串口工具以 10ms 间隔连续发数据,跑十分钟,结束后检查三件事:
| 检查项 | 期望现象 | 不通过时优先看 |
|---|---|---|
| 回显是否完整 | 发多少收多少,字节序一致 | 队列深度是否够,tx_queue_send返回值是否被忽略 |
| 线程是否还在调度 | 监控线程的 tick 打印间隔稳定 | 是否有线程死循环占满 CPU |
| 内存池是否耗尽 | tx_thread_create之外无TX_POOL_ERROR | 内存池大小、队列存储区是否重复申请 |
灌数据这个动作看着粗暴,但它能一次性暴露队列深度不够、线程优先级配反导致的饥饿、以及内存池给小了这三类问题。前两类在正常调试时几乎看不出来,因为人手敲键盘的速度根本压不满队列。
从那以后我每次拿到一份带 RTOS 的 CubeMX 工程,都会先把 Timebase Source 和 Toolchain/IDE 这两个地方翻一遍,再点 Generate,最后强制走完这三个验证动作。这套流程救过我很多次——配置本身不难,难的是错的时候不报错。希望这些能帮到你,少在时基和工具链上耗时间。
本文还有配套的精品资源,点击获取