做了这么多年嵌入式开发我可以说DMA是个“用之前觉得没必要用之后再也回不去”的外设。尤其是做串口大批量收发、ADC连续采样、或者要在内存里搬运大块数据的时候没有DMACPU就一直在那里搬数据主循环被拖得一卡一卡有了DMA数据搬运这个脏活累活直接交给硬件做CPU腾出手来处理业务逻辑。这篇文章我不准备念手册而是按我实际项目里配置STM32 DMA时的思路来写先从原理上搞清楚DMA到底干了什么再把通道映射、初始化参数这些最容易懵的地方逐个拆开然后分享串口、ADC、内存搬运这三个最高频场景的具体配置最后附上CubeMX完整配置流程和这几年我踩过的坑。内容覆盖F1和F4系列HAL库和标准库都会提到新手照着一步步做就能用起来。1. DMA到底是什么为什么你迟早要用它1.1 没有DMA的时候CPU在干什么先从一个最简单的场景说起。你用STM32的串口接收一帧数据传统做法是开一个接收中断来一个字节进一次中断服务函数把数据从USART-DR寄存器里读出来放到内存数组里。一次两次没什么感觉但如果波特率是921600一帧数据几百个字节CPU几乎每几十微秒就要被打断一次所有时间都耗在“从外设寄存器搬到内存”这种机械操作上。这种“把数据从一个地址搬到另一个地址”的动作本质上是纯粹的重复劳动根本不需要CPU去执行指令。DMADirect Memory Access直接存储器访问就是专门干这个的硬件模块。它能在不需要CPU干预的情况下自己完成外设寄存器和内存之间的数据搬移。我经常跟同事打一个比方CPU好比一个项目经理中断就是各种紧急电话每来一个电话项目经理就得放下手里的事去接。而DMA就像你请了一个专职快递员外地寄来的货直接由快递员签收、入库全部搞定之后才跟你打声招呼说“货到了”。项目经理该写方案写方案该开会开会效率一下子就不一样了。1.2 DMA的几个核心概念方向、搬运单位、指针自增配置DMA之前要先搞懂它内部是怎么工作的。一个典型的DMA传输过程要明确这几个东西传输方向Direction数据从哪到哪。常见的有三种外设到内存比如串口接收、ADC采样、内存到外设比如串口发送、DAC输出、内存到内存比如把一个大数组拷到另一个数组。数据宽度Data Alignment每次搬一个字节Byte、半个字Half Word16位还是一个整字Word32位。这个必须和外设寄存器以及内存缓冲区对齐串口DR寄存器一般是8位ADC转换结果是12位或16位所以ADC一般配半字串口配字节。地址自增Inc外设地址通常是固定的比如USART-DR寄存器永远是那一个地址所以Peripheral Increment关闭但内存缓冲区的地址要依序递增数据到了之后一个一个往后放所以Memory Increment要打开。传输模式Mode普通模式Normal搬完指定长度就停适合一次性任务循环模式Circular搬完自动从头开始适合持续接收串口数据或者连续ADC采样缓冲区会被反复覆盖。举个例子串口接收时配置成“外设到内存”外设地址固定为USART-DR内存地址递增指向rx_buffer[0]、rx_buffer[1]……每次传输一个字节触发源是“USART接收到一个字节”这个事件。DMA模块自己在背后完成一切等你设定好的长度搬完了它再抛一个“传输完成”事件出来你也可以在这个事件里做后续处理。搞懂这四个概念后面配置代码里的参数就基本能看懂了。DMA不是魔法它就是一套可配置的硬件搬运逻辑你把起点、终点、搬多少、每次搬多宽这四个信息告诉它它就能自己跑。2. 配置DMA之前先搞懂通道、优先级和初始化参数2.1 DMA1和DMA2同一套规矩两个不同班底STM32不同系列DMA模块的规模差别很大。F1系列的DMA1有7个通道DMA2有5个通道F4系列的DMA1和DMA2各有8个数据流Stream每个数据流还能对应多个通道Channel。我最早用ST标准库配置F103的时候被DMA1_Channel4、DMA1_Channel5这种东西绕得晕头转向。后来用了F4的HAL库又冒出来DMA2_Stream2_Channel4这种组合本质上表达能力变强了同一个外设可以在不同Stream上工作选定Stream之后还要指定Channel编号用来标记这个Stream和外设事件请求之间的对应逻辑关系。F1和F4的DMA差异我给一个简单对照项目STM32F1系列STM32F4系列模块规模DMA17通道DMA25通道DMA1/DMA2各有8个Stream每个Stream对应8个Channel配置粒度通道即一个DMA请求源Stream Channel组合方式一个Stream可以响应不同外设的请求典型串口映射USART1_TX对应DMA1 Channel4RX对应DMA1 Channel5USART1_TX在DMA2 Stream7 Channel4RX在DMA2 Stream2 Channel4具体看手册使用体验通道少选择简单但部分外设映射固定灵活度高但配置前必须先查映射表选DMA1还是DMA2有个使用习惯尽量把DMA2留给带宽要求高的外设因为DMA1在处理某些外设比如ADC时可能会和别的总线访问产生竞争但这只是一个参考最终还是要看你用的外设请求映射到了哪一路DMA。注意不同子系列之间同一个外设的DMA映射可能会变。比如同样是USART1_RX在某些F4型号上可能默认对应DMA2 Stream5 Channel4而另一些型号对应DMA2 Stream2 Channel4。最可靠的方法就是打开你所用型号的参考手册Reference Manual翻到“DMA request mapping overview”那张表。不要凭记忆死记硬背CubeMX生成错误配置的案例我见过太多了。2.2 外设请求与通道映射查表比死记硬背靠谱DMA之所以能“自动”搬数据靠的是外设发来的请求信号。USART接收到一个字节之后会向DMA发送一个请求ADC完成一次转换也会向DMA发送一个请求DMA收到请求之后按照你初始化好的配置搬一个数据然后等待下一个请求。关键点在于一个外设事件能触发哪个DMA通道是芯片设计时就定好的不能随便改。以F103为例USART1_RX只能使用DMA1的Channel5ADC1只能使用DMA1的Channel1。你想把USART1_RX配到DMA1 Channel3上硬件上根本行不通。到了F4系列组合方式更多一些。比如USART1_RX可以挂在DMA2的Stream2或Stream5上但需要配合指定的Channel编号。这个映射矩阵是固定的配置错了DMA不会工作。我的建议是不要试图背下所有映射关系而是学会查两张表——参考手册里的“DMA request mapping”表以及CubeMX里DMA Settings下拉框的候选列表。CubeMX会自动列出当前外设可选的所有DMA请求通道手动配置的时候照着选就行。2.3 初始化参数逐个拆解HAL库和标准库对照HAL库配置DMA时核心是一个InitTypeDef结构体。以F4系列串口接收为例CubeMX生成的初始化代码大概是这个样子hdma_usart1_rx.Instance DMA2_Stream2; hdma_usart1_rx.Init.Channel DMA_CHANNEL_4; hdma_usart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode DMA_CIRCULAR; hdma_usart1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_usart1_rx); __HAL_LINKDMA(huart1, hdmarx, hdma_usart1_rx);对照前面说的四个概念这段代码就很好理解了外设到内存方向外设地址不自增始终是USART的DR寄存器内存地址自增rx_buffer一个接一个排数据宽度都是8位工作模式是循环模式优先级高。如果用标准库F103上同样的配置是这个画风DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rx_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize RX_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure);对比一下就能发现HAL库和标准库描述的其实是同一组配置信息只是组织方式变了。标准库里DMA_BufferSize就是一次要搬的数据个数HAL库是在启动传输时传入长度。理解了DMA的工作原理换库根本不叫事。优先级这里多说一句。DMA通道/数据流之间是有仲裁的如果两个DMA同时请求优先级高的先执行。我们在配置串口接收的时候通常会把DMA优先级设成High或Very High因为串口数据如果不及时取走硬件FIFO溢出就会丢字节。但也不是所有外设都给最高优先级就好如果某个DMA在跑内存到内存的大块搬运优先级又很高其他DMA就可能被饿死要按实际场景权衡。3. 实战中最高频的三个DMA场景3.1 串口空闲中断DMA接收降低CPU负载的标准配置串口接收是我用得最多的DMA场景。裸机环境下用“DMA循环接收串口空闲中断”这个组合就能达到类似“收完一整帧再通知CPU”的效果中间一个字节都不需要CPU操心。具体思路是这样先用DMA循环模式把串口数据持续搬到一个环形缓冲区里同时开启串口的空闲中断IDLE interrupt。当一帧数据接收完毕总线上出现一个空闲状态时串口硬件会产生一个空闲中断。在这个中断里我只需要读一下DMA当前还剩下多少没搬也就是计数器的值就能算出这一帧数据的长度。HAL库下的启动代码很简洁// 启动DMA循环接收 HAL_UART_Receive_DMA(huart1, rx1_buffer, RX1_BUFFER_SIZE); // 使能串口空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);然后在串口中断服务函数里追加空闲判断void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算这一帧数据的长度 uint16_t len RX1_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 把rx1_buffer里的len个字节拷贝出来交给协议层处理 } }这里有三个细节需要特别注意。第一__HAL_DMA_GET_COUNTER拿到的是DMA剩余未传输的数量。循环模式下如果DMA已经把整个缓冲区填满并绕回开头这个计数器会反映当前写指针的位置。用缓冲区总长度减去计数器剩余值就是“这次空闲到来之前新收到的数据长度”。第二数据拷贝要及时否则下一帧数据到了之后会覆盖掉当前缓冲区内容。我一般在空闲中断里只做“搬运到应用缓冲区”和“置标志位”两件事真正协议解析放到主循环里做。第三空闲中断标志的清除方式和普通中断不同不要用__HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_IDLE)直接调用__HAL_UART_CLEAR_IDLEFLAG(huart1)因为空闲标志的清除序列比较特殊处理不当会导致中断反复触发。用这套方案之后我做过一个实际对比921600波特率下接收512字节传统逐字节中断方式CPU在接收期间的占用率超过八成换成DMA空闲中断后CPU占用率几乎可以忽略主循环的实时性一下子提上来了。3.2 ADC多通道连续采样配合DMA采集不丢数据ADC多通道扫描模式下转换完一个通道会产生一个数据转换完下一个通道又产生一个数据。如果在中断里一个值一个值地读不仅频繁打断CPU遇到中断响应不及时还容易漏读。用DMA就很优雅ADC转换完成信号直接触发DMA搬运把结果自动存进数组里。F103上的配置ADC1对应的DMA通道是DMA1 Channel1。标准库示例大致是这样的DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_value; // uint16_t adc_value[通道数] DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize CHANNEL_COUNT; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, DMA_InitStructure); ADC_DMACmd(ADC1, ENABLE); ADC_SoftwareStartConvCmd(ADC1, ENABLE);一次循环之后adc_value[0]就是第一个通道的结果adc_value[1]就是第二个通道的结果。这样CPU只需要定期读一次数组采样率高的场合也不会丢数据。用HAL库的话启动方式变成一句HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_value, CHANNEL_COUNT);数组里数据的排列顺序和你在ADC通道配置里的rank顺序一致。配置多通道ADC时要保证adc_value数组的大小不小于通道数否则DMA会越界写内存这是很多初学者容易忽略的。3.3 内存到内存搬运处理大量数据的快车道DMA不只是能在外设和内存之间搬数据还能做内存到内存的搬运。做图像处理、协议组包、大量数据预处理的时候用memcpy虽然也行但如果对速度敏感DMA搬运可以解放CPU。HAL库启动一个内存到内存的DMA传输示例代码如下DMA_HandleTypeDef hdma_mem2mem; hdma_mem2mem.Instance DMA2_Stream0; hdma_mem2mem.Init.Channel DMA_CHANNEL_0; hdma_mem2mem.Init.Direction DMA_MEMORY_TO_MEMORY; hdma_mem2mem.Init.PeriphInc DMA_PINC_ENABLE; hdma_mem2mem.Init.MemInc DMA_MINC_ENABLE; hdma_mem2mem.Init.PeriphDataAlignment DMA_PDATAALIGN_WORD; hdma_mem2mem.Init.MemDataAlignment DMA_MDATAALIGN_WORD; hdma_mem2mem.Init.Mode DMA_NORMAL; hdma_mem2mem.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_mem2mem); // 启动一次性搬运 HAL_DMA_Start(hdma_mem2mem, (uint32_t)src_buffer, (uint32_t)dst_buffer, BUFFER_SIZE); // 或者轮询等待完成 HAL_DMA_PollForTransfer(hdma_mem2mem, HAL_DMA_FULL_TRANSFER, 1000);这里有个很容易出错的地方内存到内存模式外设地址其实也是内存地址所以PeriphInc要打开让源地址也递增。如果你沿用外设到内存的习惯PeriphInc_Disable源数据就会一直读同一个地址得到的结果全是第一个元素重复。另外内存到内存的DMA传输没有外设硬件请求来触发DMA一旦启动就会连续不断地搬直到搬完设定长度。这种模式只配置成Normal模式就够用循环模式在这类场景里意义不大。4. 从CubeMX到代码落地完整走一遍DMA配置4.1 CubeMX图形化配置的关键选项很多新手喜欢用CubeMX生成初始化代码这没问题但要注意CubeMX只是帮你把结构体填充好不等于你能跳过理解。我建议用CubeMX的人一定要看着DMA Settings面板里的选项逐项对应到原理上。以STM32F407串口接收为例在CubeMX里选中USART1然后在DMA Settings选项卡里点击Add添加一个DMA Request方向选“RX”。此时CubeMX会自动给你推荐一个DMA映射比如DMA2 Stream2 Channel4同时让你设置以下参数Priority我一般选HighMode选Circular循环模式Data WidthPeripheral和Memory都选ByteIncrement AddressMemory勾选外设不勾ADC这边进入ADC1配置开启Scan Conversion Mode和Continuous Conversion Mode然后在DMA Settings里添加DMA Request。Cache这些高级设置先不管CubeMX默认值在大多数场景够用。生成代码之前还有一个地方要检查NVIC嵌套向量中断控制器设置。串口接收场景至少要开启USART1全局中断因为空闲中断是串口事件必须通过USART1_IRQHandler处理。DMA自身的中断是否开启取决于你想不想用DMA传输完成中断。循环模式下我通常不开DMA传输完成中断而用串口空闲中断来通知“一帧数据到了”。普通传输模式下选择DMA传输完成中断才合理。4.2 真正写代码时要注意的细节CubeMX生成的代码框架比较完整但有几个点必须在应用层手工补齐否则DMA跑不起来。第一启动DMA接收。CubeMX默认不会自动开启DMA传输你需要在初始化完成后手动调用HAL_UART_Receive_DMA。这个函数会同时配置好DMA并启动接收。第二空闲中断的使能。CubeMX默认不会使能空闲中断需要手动添加__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);。第三中断服务函数里的处理逻辑。CubeMX生成的stm32f4xx_it.c里面USART1_IRQHandler已经调用了HAL_UART_IRQHandler但空闲标志的处理需要你在它后面追加代码。注意追加的位置要在HAL_UART_IRQHandler之后否则可能会被它的中断处理逻辑干扰。第四如果使用DMA发送还需要处理DMA发送完成回调。HAL库中一般重写HAL_UART_TxCpltCallback在这个回调里释放信号量或者置标志位。不要在这个回调里做耗时操作因为它本身运行在中断上下文。4.3 调试验证怎么确认DMA真的在工作DMA配置完怎么确认它真的在搬数据我最常用的方法有三种。第一种直接在调试器里看内存。用STM32CubeIDE或者Keil的调试模式全速运行程序让串口工具发一帧数据然后暂停程序查看rx1_buffer数组里的内容。如果数据正确出现在数组里说明DMA接收链路是通的。第二种看DMA计数器的值。定时读取__HAL_DMA_GET_COUNTER(hdma_usart1_rx)在串口不发送数据时它应该是缓冲区总长度发送数据之后它应该变小等数据被处理完再恢复。这个计数器的变化能直观反映DMA搬运的进度。第三种逻辑分析仪或示波器观察串口引脚。如果数据确实从设备端发出初始化配置又没有问题那问题多半出在DMA方向或者地址映射上。这个时候直接检查外设初始化结构体里的每项配置对照参考手册找差异比反复试错快得多。调试时还有一个很常见的现象程序一跑rx1_buffer里面全是0xFF或者乱码。这通常不是DMA的问题而是串口波特率、数据位、停止位配置不一致导致的接收错误。DMA只是把收到的东西原样搬进内存它没有能力修正物理层的通信错误。先把串口通信本身调通再打开DMA排查思路会清晰很多。5. DMA使用中的常见问题与排查技巧实录5.1 数据错位、乱码、卡死别急着改代码先查这几个地方我在论坛里看到过不少人问DMA相关的问题归结起来无非三类数据错位、乱码、程序卡死。排查顺序我给出一个自己的习惯先确认外设自身通信是否正常。关掉DMA用阻塞方式或普通中断方式接收看数据是不是对的。如果阻塞方式下数据本身就有问题那就是串口参数或者电路问题DMA不背这个锅。再确认DMA地址配置。外设地址必须指向外设的数据寄存器比如USART1-DR、ADC1-DR内存地址指向一个不小于缓冲区大小的数组。地址写错轻则数据不对重则直接HardFault。然后确认数据宽度一致。串口8位数据就用ByteADC 12位/16位结果就用HalfWord。Peripheral和Memory两边宽度必须匹配。如果你用的是Word宽度去接收8位串口数据内存里就会出现大量空白字节。最后查缓冲区大小和模式匹配。普通模式下DMA搬完设定的长度就停了如果串口之后又来了数据就会丢循环模式则要注意缓冲区覆盖问题数据处理速度跟不上接收速度就会出现“上一帧还没处理完就被下一帧覆盖”的丢包。程序卡死这个现象多半不是DMA本身导致而是DMA搬到了不该搬的地方破坏了内存数据。比如缓冲区数组定义得太小DMA写越界恰好把某个中断向量表或者关键的全局变量覆盖了程序跑飞就很正常了。解决这类问题用调试器观察PC指针停在哪个中断或异常里回头查DMA的地址配置和缓冲区大小通常能找到线索。5.2 缓存一致性问题F4/F7/H7系列的特殊陷阱用带Cache的STM32比如F7、H7跑DMA时有个特别隐蔽的坑——缓存一致性问题。简单说CPU写数据时会先写到Cache里再异步刷到内存DMA访问内存时不经过Cache。如果CPU刚往一块内存写了数据DMA就把它搬走DMA可能读到的是旧数据反过来DMA往内存写了数据CPU去读时可能读的是Cache里的旧值。F4系列没有独立的D-CacheF4也有相应的处理要求但一般默认没问题F7和H7上如果启用了D-Cache就必须在DMA传输前做Cache Clean在DMA接收后做Cache Invalidate。HAL库提供了对应接口SCB_CleanDCache(); // DMA发送前把CPU写入的数据刷到内存 SCB_InvalidateDCache(); // DMA接收后让CPU重新从内存读取数据如果你的F7/H7工程开了D-CacheDMA数据莫名其妙错乱先不要怀疑DMA配置把Cache的Clean和Invalidate加上试试。这算是我在项目里印象最深的一次“冤案”H7跑以太网DMA收包总是一会儿正常一会儿乱码排查了三天最后发现是忘了做Cache Invalidate。后来只要涉及DMA和Cache交互我第一反应就是缓存一致性。5.3 实际项目中的其他经验中断优先级、主循环负载和DMA联动串口DMA接收的项目中断优先级一定要设计好。串口空闲中断优先级如果设得过高频繁打断其他中断会导致系统实时性下降设置得过低又可能在主循环忙的时候漏掉空闲中断导致一帧数据迟迟不被处理。我一般把串口中断优先级设在中间偏上具体数值取决于你的系统中哪些中断更重要。中断回调里的逻辑也很有讲究。HAL_UART_TxCpltCallback这类回调函数运行在中断上下文里面绝对不能做阻塞操作比如加延时、等待信号量、打印日志。我见过有人在DMA接收完成回调里做字符串拼接和格式化结果回调执行时间过长后续的数据直接丢失。正确的做法是回调里只做小任务和置标志位或者用一个简短的消息队列把数据地址和长度传出去真正的解析放到主循环。DMA配合外设时还要注意“传输未完成就被其他外设打断”的情况。比如你用DMA发送串口数据发送过程中如果又调了一次HAL_UART_Transmit_DMA底层配置会被重置上一次传输可能直接夭折。正确的做法是在发送前检查DMA状态确认上一次传输已经完成再启动下一次。类似的问题在ADC定时触发采样时也会出现定时器频率太高DMA还没搬完上一批数据新一轮采样又来了处理不及时就会覆盖掉数据。说到底DMA使用中的大部分问题根源不是你不会写DMA初始化而是你对整体数据流缺乏预判。每次配置之前把“谁产生数据、产生多快、数据放到哪儿、谁去消费数据、消费多快”这五个问题想清楚DMA的坑基本能避开八成。我个人在实际项目里的体会是DMA配置这件事资料虽多但真正帮到我的还是“慢下来做映射表确认”这个动作。每个新项目拿到芯片后我都会花十几分钟翻参考手册里的DMA请求映射表再把用到的外设、方向、通道抄在一张纸上对应到CubeMX配置界面里逐项核对。这个习惯帮我免掉了好几次“查半天发现是通道选错”的尴尬。如果你也被DMA配置折腾得头疼不妨试试这个方法比反复试错稳妥得多。