1. 从“点灯”到“系统”:STM32理论到底在讲什么
很多人第一次接触STM32,都是从一块最小系统板和一根下载线开始的。打开Keil或者CubeIDE,新建工程,写几行初始化代码,把GPIO配置成推挽输出,然后让LED闪烁起来。那一刻确实很有成就感,但接下来往往就卡住了:中断优先级怎么配、时钟树怎么理解、DMA为什么搬运不完整、串口接收为什么丢数据、定时器捕获测频率为什么总是不准。这些问题的根源,其实都指向同一个东西——对STM32理论体系的理解深度不够。
我做了十多年嵌入式开发,带过不少新人,也看过大量基于STM32的毕业设计和量产项目。一个很明显的规律是:凡是能把项目做稳的人,对STM32的系统架构、时钟体系、中断机制、外设工作原理都有比较清晰的认识;而那些只会“抄代码、改引脚”的人,项目一旦稍微复杂一点,就会陷入无穷无尽的调试泥潭。所以这篇内容,我想把STM32的理论框架系统地梳理一遍,不是照本宣科地念手册,而是结合我实际踩过的坑,讲清楚每个理论点到底解决什么问题、在项目里怎么体现、调试时怎么用。
这篇文章适合谁看?如果你已经能点亮LED、能跑通串口,但对STM32的整体运作机制还是一团浆糊,那这篇内容就是为你准备的。如果你正在做基于STM32的毕业设计,或者准备把STM32用到实际产品里,那更应该把理论底子打牢。我会从系统架构讲到时钟树,从中断机制讲到外设原理,再结合定时器、串口、I2C、CAN这些高频外设,把理论落到实操上。全程不堆砌术语,尽量用生活化的类比把复杂概念讲透。
2. STM32系统架构与内核理论:为什么理解总线结构能少走弯路
2.1 Cortex-M内核与STM32的关系
很多人会把STM32和Cortex-M混为一谈,其实这是两个层面的东西。Cortex-M是ARM公司设计的处理器内核架构,STM32是ST公司基于这个内核加上各种外设、存储器、总线做出来的完整微控制器。打个比方,Cortex-M就像是一台发动机的设计图纸,STM32则是用这台发动机造出来的一辆完整汽车,还配了变速箱、底盘、电气系统。
理解这个分层关系很重要,因为它决定了你查资料的方式。当你遇到的是内核相关的问题,比如中断优先级、SysTick、指令集、堆栈操作,那就要去查ARM的Cortex-M编程手册;当你遇到的是外设相关的问题,比如GPIO模式、USART波特率、ADC采样时间,那就要去查ST的参考手册。我见过不少新手拿着STM32的参考手册去查中断向量表的底层机制,结果越查越糊涂,就是因为没搞清楚这两层的关系。
Cortex-M内核目前主流的有M0、M0+、M3、M4、M7、M33等系列。M0和M0+是入门级,指令集精简,成本低,适合简单控制场景;M3是经典款,性能均衡,STM32F1系列就是基于M3;M4带DSP指令和浮点单元,适合做信号处理和电机控制,STM32F4系列就是代表;M7性能更强,带缓存和TCM,适合高实时性场景。选型的时候,如果你的项目涉及FOC电机控制、音频处理、复杂算法,那M4起步会比较稳妥;如果只是简单的逻辑控制和通信,M0或M3就够用了。
2.2 总线矩阵与存储器映射
STM32内部的总线结构,是理解性能瓶颈的关键。以STM32F4为例,它有一个多层AHB总线矩阵,把Cortex-M4内核、DMA控制器、以太网MAC、USB OTG、存储器控制器等主设备连接起来,让它们可以并行访问不同的从设备。这就好比一个城市的交通网络,如果只有一条主干道,所有车辆都挤在一起,肯定堵;如果有多条高架和环路,不同方向的车流就能同时通行。
具体到实际项目里,这个理论怎么体现?举个例子,你用DMA把ADC采集的数据搬到内存,同时CPU在Flash里取指令执行算法。如果DMA和CPU访问的是同一条总线、同一个存储器区域,那就会产生总线仲裁,CPU的执行效率会下降。但如果你把DMA的目标缓冲区放在CCM RAM(内核紧耦合内存)里,而CPU的指令在Flash里,两者走不同的总线路径,就能真正并行。我在做音频采样项目时就吃过这个亏,一开始把DMA缓冲区放在普通SRAM里,发现CPU处理速度总是上不去,后来改到CCM RAM,效率明显提升。
存储器映射也是必须搞清楚的基础。STM32的4GB地址空间被划分成几个固定区域:Code区(Flash)、SRAM区、外设区、外部RAM区等。每个外设的寄存器都有固定的地址,比如GPIOA的基地址是0x40020000,你操作GPIOA->ODR实际上就是在读写这个地址。理解这一点,你就能明白为什么可以用指针直接操作寄存器,也能明白为什么有些地址访问会触发HardFault。
2.3 启动流程与向量表
STM32上电之后的第一件事,是从Flash的0x08000000地址取出前4个字节作为初始栈顶指针,再取出接下来4个字节作为复位向量地址,然后跳转到复位处理函数。这个复位处理函数通常是启动文件里写的Reset_Handler,它会调用SystemInit配置时钟,然后调用__main初始化C库,最后进入main函数。
这个流程看起来简单,但实际调试中很多问题都跟它有关。比如你修改了链接脚本,把向量表重定向到别的地址,但忘记设置SCB->VTOR寄存器,那中断就会跳转到错误的地方。再比如你在Bootloader和APP之间跳转,如果没有正确设置MSP和VTOR,APP里的中断就会跑飞。我在做IAP升级功能时,就专门花时间研究了向量表重定位的机制,最后在APP的main函数开头加上SCB->VTOR = APP_BASE_ADDR,问题才解决。
向量表的本质是一个函数指针数组,每个中断源对应一个入口。Cortex-M的向量表前16个是内核异常,后面是外设中断。理解这个结构,你就能明白为什么中断服务函数的名称必须和启动文件里的一致,也能明白为什么修改中断优先级实际上是在操作NVIC的寄存器。
3. 时钟体系与电源管理:系统稳定的根基
3.1 时钟树到底怎么理解
STM32的时钟树是很多初学者的噩梦,各种PLL、分频器、选择器看得人眼花缭乱。但如果你把它想象成一个自来水厂供水系统,就很好理解了。时钟源就是水源,有内部的高速RC振荡器(HSI)、内部低速RC(LSI)、外部高速晶振(HSE)、外部低速晶振(LSE)。这些水源经过PLL这个“增压泵”倍频之后,再通过AHB、APB1、APB2这些“分水阀”分配到各个外设。
以STM32F103为例,常见配置是HSE 8MHz,经过PLL 9倍频得到72MHz作为系统时钟。然后AHB不分频,APB1二分频得到36MHz,APB2不分频得到72MHz。为什么APB1要二分频?因为APB1上挂的外设(如USART2、TIM2、I2C1)最高只能跑36MHz,而APB2上的外设(如GPIOA、USART1、TIM1)可以跑72MHz。如果你不查手册,把APB1的外设时钟配成72MHz,轻则通信异常,重则外设直接不工作。
这里有个实操心得:配置时钟的时候,一定要用STM32CubeMX或者手动计算一遍每个总线的频率,然后跟参考手册里的外设最大频率对照。我见过一个项目,USART2的波特率总是对不上,查了半天发现是APB1的分频系数设错了,导致实际时钟跟预期不一致,波特率自然就偏了。
3.2 PLL配置与时钟安全系统
PLL的配置涉及多个参数:输入分频、倍频系数、输出分频。以F4系列为例,假设HSE是8MHz,想要得到168MHz的系统时钟,可以这样算:先8MHz经过M分频(比如M=8)得到1MHz的VCO输入,然后经过N倍频(比如N=336)得到336MHz的VCO输出,再经过P分频(比如P=2)得到168MHz的系统时钟。同时还有Q分频用于USB等外设。
这些参数不是随便填的,VCO输入频率通常要求在1到2MHz之间,VCO输出频率要在100到432MHz之间。如果你填错了,PLL可能锁定不了,系统就会自动切回HSI,导致所有基于HSE的时序全部乱套。我在调试一个USB项目时,就遇到过PLL配置不当导致USB枚举失败的问题,后来用示波器测量MCO引脚输出的时钟,才发现实际频率跟预期差了很远。
时钟安全系统(CSS)是一个很实用的功能,当HSE失效时,硬件会自动切换到HSI,并产生一个中断。你可以在这个中断里做一些安全处理,比如关闭危险外设、记录故障日志。这个功能在工业控制场景里特别重要,因为外部晶振受温度、振动影响可能会停振,如果没有CSS,系统就会直接死机。
3.3 低功耗模式与唤醒源
STM32支持睡眠、停止、待机三种低功耗模式。睡眠模式只关闭内核时钟,外设还在跑;停止模式关闭所有时钟,但SRAM和寄存器内容保留;待机模式最省电,但SRAM内容丢失,唤醒后相当于复位。
选择哪种模式,取决于你的应用场景。比如一个电池供电的传感器节点,大部分时间在采集数据,那可以用停止模式,定时器唤醒后采集一次,然后继续睡。如果是一个需要保持RAM数据的设备,那就不能用待机模式。我在做一个鱼缸控制器项目时,就用了停止模式配合RTC唤醒,整体功耗从几十毫安降到了几百微安,电池寿命从几天延长到了几个月。
唤醒源的配置也有讲究。比如用RTC唤醒,需要配置RTC闹钟;用外部中断唤醒,需要配置EXTI线;用串口唤醒,需要配置USART的唤醒功能。这里有个坑:从停止模式唤醒后,系统时钟会默认切回HSI,如果你的外设依赖HSE,就需要在唤醒后重新配置时钟。我一开始没注意这一点,唤醒后串口通信总是乱码,后来在唤醒处理里重新调用SystemInit才解决。
4. 中断与异常机制:实时性的核心保障
4.1 NVIC优先级分组与抢占
Cortex-M的NVIC支持中断嵌套,优先级分为抢占优先级和子优先级。抢占优先级高的可以打断抢占优先级低的中断,子优先级只在同时挂起时决定谁先执行。STM32的分组方式有5种,从NVIC_PRIORITYGROUP_0到NVIC_PRIORITYGROUP_4,区别在于给抢占优先级和子优先级各分配几位。
这个机制在实际项目里怎么用?举个例子,你做电机控制,PWM更新中断需要最高实时性,那抢占优先级就要设高;串口接收中断可以低一些;SysTick可以最低。但要注意,抢占优先级越高,嵌套层数越多,栈空间消耗越大。如果栈设得太小,深层嵌套时就会栈溢出,导致HardFault。
我踩过的一个坑是:在中断服务函数里调用了printf,结果系统时不时死机。原因是printf内部可能用到信号量或者动态内存,这些操作在中断上下文里是不安全的。后来改成在中断里只置标志位,在主循环里处理打印,问题就消失了。所以记住一个原则:中断服务函数要尽可能短,只做最紧急的事,复杂处理放到主循环。
4.2 外部中断与事件控制器
EXTI可以把GPIO的边沿变化映射成中断或事件。中断会触发CPU跳转执行ISR,事件只是给其他外设一个触发信号,不占用CPU。比如你可以用EXTI事件触发ADC采样,这样就不需要CPU介入。
配置EXTI的时候,要注意GPIO和EXTI线的映射关系。STM32的EXTI线是有限的,PA0、PB0、PC0共用EXTI0线,同一时刻只能选一个。如果你同时要用PA0和PB0做中断,那就不行,得换引脚。我在做矩阵键盘项目时,就因为这个限制重新调整了引脚分配。
还有一个常见问题是按键抖动。机械按键按下和松开时会有几十毫秒的抖动,如果直接进中断,会触发多次。解决方法可以是硬件加RC滤波,也可以是软件在中断里延时再读,或者用定时器做消抖。我一般用定时器方案:EXTI中断触发后启动一个10ms定时器,定时器到了再读GPIO状态,这样既不影响实时性,又能可靠消抖。
4.3 中断向量表重定位与Bootloader
前面提到过向量表重定位,这里展开讲一下。在Bootloader+APP的架构里,Bootloader通常放在Flash起始地址,APP放在偏移地址。APP运行前,需要把向量表重定位到自己的起始地址,否则中断会跳到Bootloader的向量表里。
具体操作是设置SCB->VTOR = APP_BASE_ADDR。但这里有个细节:VTOR的低几位是有对齐要求的,APP的起始地址必须按照向量表大小对齐。比如向量表有84个条目,每个4字节,那就是336字节,需要按128字节对齐(因为VTOR的低7位保留)。所以APP的起始地址最好是0x08004000、0x08008000这种。
另外,从Bootloader跳转到APP之前,要关闭所有中断,设置MSP为APP的栈顶,然后跳转到APP的复位向量。我见过有人跳转后忘了关中断,结果APP还没初始化完就进了中断,直接HardFault。这个坑在写IAP升级功能时特别容易踩。
5. 定时器理论:从延时到PWM到输入捕获
5.1 定时器的基本工作原理
STM32的定时器本质上是一个计数器,时钟源经过预分频器(PSC)分频后驱动计数器(CNT)计数,计数到自动重装载寄存器(ARR)的值时产生更新事件,然后重新计数。计数模式有向上、向下、向上/向下三种。
定时时间的计算公式是:定时时间 = (PSC+1) * (ARR+1) / 定时器时钟频率。比如定时器时钟是72MHz,想要1ms定时,可以设PSC=71,ARR=999,这样(71+1)*(999+1)/72000000 = 1ms。这个公式看起来简单,但实际配置时要注意ARR的更新时机。如果开启了ARPE(自动重装载预装载),那ARR的修改会在下一个更新事件生效;如果没开,修改立即生效。这个区别在动态调整PWM频率时很关键。
我做过一个项目,需要动态调整PWM频率来驱动蜂鸣器播放音乐。一开始没开ARPE,改ARR的时候偶尔会出现一个异常宽的脉冲,导致蜂鸣器发出杂音。后来开了ARPE,问题就解决了。所以记住:需要动态改ARR的时候,最好开启预装载。
5.2 PWM输出与死区控制
PWM模式是定时器最常用的功能之一。通过配置捕获/比较寄存器(CCR),可以控制占空比。PWM模式1和模式2的区别在于有效电平的极性。模式1是CNT<CCR时输出有效电平,模式2是CNT<CCR时输出无效电平。
在电机控制里,H桥的两路PWM需要死区时间,防止上下桥臂直通。STM32的高级定时器(TIM1、TIM8)支持硬件死区插入,通过BDTR寄存器的DTG位配置。死区时间的计算公式跟具体系列有关,以F1为例,DTG[7:5]=0xx时,死区时间=DTG[7:0]*Tdtg,其中Tdtg是定时器时钟周期。我一般用示波器实测死区时间,确保在几百纳秒到几微秒之间,既能防止直通,又不会明显影响输出波形。
做FOC控制的时候,PWM的频率、死区、对齐方式都会影响控制效果。中心对齐模式比边沿对齐模式产生的谐波更小,但中断频率翻倍,CPU负载更高。我一般根据电机参数和控制频率来权衡,低速电机用中心对齐,高速电机用边沿对齐。
5.3 输入捕获测频率与占空比
输入捕获的原理是:当引脚上出现指定边沿时,硬件自动把当前CNT的值锁存到CCR寄存器,并产生中断。通过两次捕获的CNT差值,就能算出信号周期,进而得到频率。
测频率的时候,有个经典问题:如果信号频率很低,CNT会溢出多次,需要处理溢出。如果信号频率很高,CNT差值很小,精度不够。解决方法可以是:低频时用溢出计数扩展,高频时用预分频器降低定时器时钟。我在做超声波测距项目时,就用输入捕获测量回波脉冲宽度,通过捕获上升沿和下降沿,算出高电平时间,再换算成距离。
这里有个实操技巧:输入捕获的中断里不要做复杂计算,只记录CCR值和溢出次数,主循环里再算频率。因为高频信号的中断频率很高,如果ISR太长,会丢中断。我一般用DMA配合输入捕获,让DMA自动搬运CCR值,CPU只在缓冲区满的时候处理一次,这样能测到很高的频率。
6. 通信外设理论:串口、I2C、SPI、CAN
6.1 USART串口通信的深层机制
串口看起来简单,但要用好并不容易。STM32的USART支持多种模式:同步、异步、单线半双工、多处理器、LIN、IrDA、智能卡等。异步模式最常用,配置波特率、数据位、停止位、校验位就能通信。
波特率的计算公式是:波特率 = fCK / (16 * USARTDIV),其中USARTDIV是一个定点数,整数部分在BRR的高12位,小数部分在低4位。比如fCK=72MHz,想要115200波特率,USARTDIV = 72000000/(16115200) = 39.0625,整数部分39,小数部分0.062516=1,所以BRR=0x271。如果算错了,通信就会出错。我一般用CubeMX自动算,但心里要清楚这个公式,调试时才能判断问题。
串口接收丢数据是常见问题。原因可能是:中断优先级太低被其他中断打断、接收缓冲区太小、没有及时读取DR寄存器导致溢出。解决方法可以是:用DMA接收、加大缓冲区、提高中断优先级、用空闲中断判断一帧结束。我在做Modbus通信时,就用空闲中断+DMA的方式,一帧数据接收完才处理,效率很高。
6.2 I2C总线的时序与仲裁
I2C是两线制总线,SCL和SDA都是开漏输出,需要外部上拉电阻。通信过程包括起始条件、地址帧、数据帧、应答位、停止条件。STM32的I2C外设支持主机和从机模式,支持7位和10位地址。
I2C最容易出问题的地方是时序和上拉电阻。上拉电阻太大,上升沿太慢,高速通信时数据出错;上拉电阻太小,功耗增加,低电平可能拉不下去。一般4.7k到10k比较常用,具体要看总线电容和通信速率。我在调试BH1750光照传感器时,一开始用10k上拉,100kHz通信正常,但400kHz就出错,后来换成4.7k就好了。
还有一个坑是I2C的死锁。如果主机在通信过程中复位,从机可能还在等待时钟,导致SDA被拉低,总线锁死。解决方法可以是:在初始化时发送9个时钟脉冲,让从机释放总线。或者用硬件I2C的超时机制,超时后重新初始化。
6.3 SPI与CAN的实战要点
SPI是全双工同步通信,四根线:SCK、MOSI、MISO、CS。STM32的SPI支持多种时钟极性和相位组合,配置时要跟从机匹配。SPI的速率可以很高,但要注意信号完整性问题,长距离通信时可能需要加缓冲器。
CAN总线在汽车和工业领域很常用,STM32的CAN外设支持CAN 2.0A和2.0B。CAN通信突然连不上,常见原因有:波特率不匹配、终端电阻缺失、总线电容过大、节点地址冲突。我遇到过CAN通信跑一段时间就断的情况,后来发现是终端电阻没接,信号反射导致误码率升高。加上120欧终端电阻后,通信就稳定了。
CAN的过滤器配置也是个难点。STM32的CAN有多个过滤器组,可以配置成掩码模式或列表模式。如果过滤器配错了,可能收不到想要的报文,或者收到一堆无关报文。我一般先用列表模式接收特定ID,调试通了再改成掩码模式提高效率。
7. 常见问题与排查技巧实录
7.1 下载与调试问题速查
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| Keil下载报错Flash Download failed | 芯片型号选错、Flash算法不对、下载线接触不良 | 检查Device选型、确认Flash算法、重新插拔下载线 |
| 程序下载后不运行 | 启动模式不对、复位电路异常、晶振没起振 | 检查BOOT引脚、测量复位引脚、用示波器看晶振 |
| 调试时断点不生效 | 优化等级太高、代码在Flash里但断点设在RAM | 降低优化等级、确认断点地址有效 |
| 串口打印乱码 | 波特率不对、时钟配置错误、TX/RX接反 | 核对波特率、检查时钟树、交换TX/RX |
7.2 外设初始化失败排查思路
外设初始化失败,我一般按这个顺序排查:先看时钟有没有使能,再看引脚有没有配置成正确的复用功能,然后看外设的寄存器配置是否符合手册要求,最后看中断有没有使能、优先级有没有配错。
举个例子,SPI通信失败,先确认SPI时钟使能了,再确认SCK、MOSI、MISO、CS引脚配置成了复用推挽或复用开漏,然后检查SPI的CPOL、CPHA、数据位、波特率预分频是否跟从机匹配,最后看有没有使能SPI。这个顺序能覆盖大部分问题。
7.3 中断相关问题的独家避坑技巧
中断相关的问题往往比较隐蔽,我总结了几条经验:第一,中断服务函数里不要用浮点运算,除非你确认FPU上下文保存没问题;第二,中断里不要调用可能阻塞的函数,比如malloc、printf、延时;第三,中断优先级分组一旦设定,整个工程最好统一,不要在不同文件里改来改去;第四,用NVIC_Pending寄存器可以手动触发中断,调试时很有用;第五,如果中断进不去,先检查NVIC的使能位和优先级,再检查外设的中断使能位,最后检查全局中断有没有开。
我在调试CAN通信时,就遇到过中断进不去的问题。查了半天发现是CAN的接收中断使能位没开,只开了CAN外设的时钟和初始化,忘了开中断。这种问题看代码很难发现,用调试器单步跟踪寄存器才找到。
8. 工具链与开发环境理论
8.1 Keil、IAR与VSCode的选型考量
Keil是国内最常用的STM32开发工具,优点是资料多、上手快、调试功能完善;缺点是编辑器体验一般、代码补全弱、收费。IAR的编译效率高、优化好,但界面老旧、学习曲线陡。VSCode加插件的方式越来越流行,编辑器体验好、免费、可定制性强,但调试配置相对复杂。
我现在的习惯是:日常开发用VSCode加Cortex-Debug插件,编译用Makefile或CMake,调试用OpenOCD或J-Link。这样既享受了VSCode的编辑体验,又能灵活控制编译流程。配置launch.json的时候,关键是servertype和device要跟实际调试器匹配,svdFile要指向对应芯片的SVD文件,这样才能在调试时查看外设寄存器。
8.2 标准库、HAL库与LL库的取舍
ST官方提供了三种库:标准库(SPL)、HAL库、LL库。标准库最接近寄存器,代码效率高,但ST已经不再维护;HAL库抽象程度高,跨系列移植方便,但代码体积大、效率略低;LL库介于两者之间,轻量且高效,但覆盖的外设不如HAL全。
新手我建议从HAL库入手,因为CubeMX可以自动生成初始化代码,省去很多配置时间。等熟悉了之后,可以逐步用LL库或者直接操作寄存器来优化关键代码。我在做量产项目时,通常用HAL库做初始化,关键的中断服务函数和性能敏感部分用LL库或寄存器操作,这样兼顾开发效率和运行效率。
8.3 调试工具与波形观测
调试STM32,光靠printf是不够的。逻辑分析仪和示波器是必备工具。逻辑分析仪可以抓SPI、I2C、串口的时序,直观看到数据对不对;示波器可以看PWM波形、电源纹波、晶振起振情况。
Keil的Logic Analyzer功能也很实用,可以在调试时观察变量变化,但它是软件模拟的,实时性不如硬件工具。我一般用Keil看变量,用逻辑分析仪看时序,用示波器看模拟信号。三者配合,大部分问题都能定位。
还有一个技巧:用GPIO翻转来测量代码执行时间。在函数入口拉高一个GPIO,出口拉低,用示波器测高电平时间,就能知道函数执行了多久。这个方法简单粗暴,但非常有效,尤其是在优化算法的时候。
9. 从理论到项目:把知识串起来
理论学了一堆,最终还是要落到项目上。我拿一个典型的基于STM32的毕业设计来举例:智能台灯。这个项目涉及光敏传感器采集、PWM调光、按键控制、OLED显示、串口通信。光敏传感器用ADC采集,需要理解ADC的采样时间、参考电压、分辨率;PWM调光用定时器,需要理解PWM模式和占空比计算;按键用EXTI或轮询,需要理解消抖和中断;OLED用I2C,需要理解I2C时序;串口通信用USART,需要理解波特率和中断接收。
你看,一个简单的项目就把时钟、中断、定时器、ADC、I2C、USART全串起来了。如果你对每个模块的理论都清楚,做起来就很顺;如果只是抄代码,那任何一个环节出问题都会卡住。
我在带新人时,经常让他们先画一张系统框图,把每个外设的时钟来源、引脚分配、中断优先级、数据流向都标出来。这张图画清楚了,项目就成功了一半。剩下的就是逐个模块调试,用前面讲的排查方法定位问题。
最后分享一个我个人的习惯:每做一个项目,都整理一份“踩坑记录”,把遇到的问题、原因、解决方法写下来。下次遇到类似问题,直接翻记录就行。这个习惯坚持几年,你会发现自己的调试速度越来越快,因为大部分坑你都踩过了。STM32的理论体系虽然庞大,但核心的东西就那些,理解透了,剩下的就是熟练度的问题。