2026/9/17 16:13:38

STM32串口DMA实战指南:原理、CubeMX配置与高频坑解析

STM32串口DMA实战指南:原理、CubeMX配置与高频坑解析 串口调试这事儿很多刚刚从标准库转到HAL库、或者直接被CubeMX“毒害”的朋友都会遇到一个很尴尬的阶段HAL_UART_Transmit用得顺手点灯、发日志都没毛病可一旦数据量上来、或者需要高频收发就会明显发现整个程序被串口“绑架”了——CPU 全耗在傻等寄存器标志位上。等你开始意识到该用 DMA 来解放 CPU接着又会掉进“DMA 配置了不工作”“发送两三次就卡死”“接收不到完整一帧”这类大坑。这篇文章是 STM32CubeMX 系列教程的第 5 篇也是我实战中反复踩坑后整理出来的总结专门把 DMA 这件事从上到下拆清楚DMA 的原理、CubeMX 里每一项配置的含义、串口收发怎么结合 DMA 写代码、以及几个网上问烂了的高频坑到底怎么解决。不管你是刚装好 CubeMX 的新手还是被 DMA 发送偶发卡死折磨过的老玩家这篇里的代码和思路都能直接拿过去参考。1. 没有DMA时串口在一秒里浪费了多少CPU时间先说清楚一个底层事实无论是标准库还是 HAL 库串口发送一个字节本质是往数据寄存器DR里写数据然后等硬件把这一字节从移位寄存器按位发出去。发送完成的标志通常是TXE发送数据寄存器空或TC发送完成。阻塞式发送就是“读标志、写数据”无限循环。1.1 串口搬运一帧数据的完整路径以波特率 115200、发送 100 字节为例。串口每传一字节实际占用 10 bit8 位数据 1 位起始 1 位停止若带奇偶校验则更多。算一下单字节耗时 10 / 115200 ≈ 86.8 微秒 100 字节耗时 ≈ 8.68 毫秒这 8.68 毫秒里CPU 如果用的是HAL_UART_Transmit阻塞发送那就是完全空转的。主循环被暂停按键扫描停摆传感器数据处理全部延后。如果你的程序里还有多个串口要轮流发日志那 CPU 空转的时间几乎是灾难级别。有人会说那我用中断发送不就行了每发一个字节进一次中断主程序照样可以干别的。这个思路方向对但代价是中断次数 数据量。100 字节就要进 100 次发送中断。虽然单次中断很短但累积起来依然会抢占 CPU并且每次中断还要做压栈出栈、保存恢复现场这些时间都是实打实的开销。更关键的是如果系统里还有定时器中断、外部中断、ADC 采集中断等实时性要求更高的任务串口中断一多优先级冲突和响应延迟问题就全都冒出来了。1.2 中断发送的局限在哪里用中断发送除了频率高还有一个编程上容易引发 Bug 的地方缓冲区管理。中断发送本质上是“边发边取”你传给串口的发送缓冲区在最后一个字节发完之前绝对不能释放或覆盖。很多新手在这里出问题第一次调用HAL_UART_Transmit_IT后不等待发送完成直接把 buffer 内容改了结果串口发出去的是篡改后的数据或者干脆乱码。1.3 DMA解决的核心问题DMADirect Memory Access直接存储器访问解决的就是上述“CPU 搬砖”的问题。它的本质是一个独立于 CPU 的硬件搬运工可以自动把数据从内存搬到外设串口发送或从外设搬到内存串口接收搬完一整个数据块之后再通过中断告诉 CPU 一声“我搞定了”。用 DMA 发送 100 字节CPU 只需要做两件事启动 DMA、等待完成中断。中间那 8.68 毫秒CPU 全都可以拿去跑主逻辑。数据量越大DMA 的优势越明显。这就是为什么做通信、做日志、做 OTA 升级、做上位机交互DM A 是绕不开的一环。2. CubeMX中串口DMA的配置项每一项都别选错CubeMX 的出现把配置门槛降了一大截但很多朋友双击串口看到DMA Settings选项卡里那一堆参数就头晕。这里先以最常见的 USART1 为例讲清楚每个配置项的含义和选法。2.1 在CubeMX里找到DMA入口并添加TX/RX请求工程建好之后按如下路径操作左侧Categories选择Connectivity USART1。在Mode中点击Asynchronous开启异步串口模式。此时下面会出现DMA Settings选项卡。进入DMA Settings点击Add可以看到两个可添加的 DMA 请求USART1_TX和USART1_RX。分别添加。添加完成后你会看到两行配置条目分别对应发送通道和接收通道。F103 上默认会分配给DMA1_Channel4TX和DMA1_Channel5RXCubeMX 已经根据硬件映射表帮你选好通道这个不需要手动干预。如果你用的是 F4、H7 系列则可能是 DMA2 的某个 Stream流。不同系列 Channel/Stream 的叫法不同但配置思路完全一致。2.2 关键参数的选法及理由以下是我在实际项目里经过大量测试后认为最适合串口场景的配置组合配置项TX发送RX接收说明DirectionMemory To PeripheralPeripheral To Memory数据是从内存搬到外设发送还是从外设搬到内存接收PriorityLow / Medium 视情况Medium / High串口速率不高时 Low 就够大量数据收发时建议 RX 优先级高于 TXModeNormalCircular 或 Normal发送用 Normal接收不定长数据建议 Circular详见下文Increment AddressMemory 勾选Peripheral 不勾选Peripheral 不勾选Memory 勾选外设地址固定内存缓冲区地址逐字节递增Data WidthByteByte串口一个寄存器一次传一个字节选 Byte很多教程直接让你照抄这些配置但没解释Increment Address到底为什么这样勾。这里我特意说明一下Peripheral指的是外设寄存器地址串口DR地址它只有一个固定地址永远不需要递增所以必须保持Disable。Memory指的是你定义的缓冲区地址DMA 要把一坨数据依次写到连续的缓冲区里所以Memory一定要Enable。如果你关掉了内存地址自增那 DMA 就会把所有字节都搬运到缓冲区的第一个位置后发的数据覆盖先发的数据收到的数据永远是最后一个字节。Data Width选 Byte是因为串口DR寄存器本身是 8 位的。虽然有些芯片支持写 RDR 时也能按半字或字访问但实际串口数据就是字节流选 Byte 最简单且不容易出问题。选 Half Word 或 Word 需要严格对齐缓冲区反而容易引入额外的坑。2.3 生成代码后HAL库替你做了什么配置完成后点击生成代码CubeMX 会在mx_usart1_uart_init()中自动创建两个 DMA 初始化结构体并通过__HAL_LINKDMA把 DMA 句柄挂到串口句柄上。核心代码类似static void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart1); hdma_usart1_tx.Instance DMA1_Channel4; hdma_usart1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_usart1_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_tx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_tx.Init.Mode DMA_NORMAL; hdma_usart1_tx.Init.Priority DMA_PRIORITY_LOW; HAL_DMA_Init(hdma_usart1_tx); __HAL_LINKDMA(huart1, hdmatx, hdma_usart1_tx); }注意__HAL_LINKDMA这一步非常关键。它建立了外设句柄和 DMA 句柄之间的连接之后你调用HAL_UART_Transmit_DMA时HAL 库会自动通过这个连接找到对应的 DMA 通道并启动搬运。如果你是自己手写初始化而没有做这个 Link后面所有 DMA 函数都会让你莫名其妙地卡死或收不到数据。3. 串口发送DMA把阻塞发送改成DMA发送的正确姿势CubeMX 帮你配置好之后HAL 库并不会自动使用 DMA 发送。它只是完成了底层初始化真正发送还是靠你调用相应函数。3.1 发送DMA的核心函数与调用逻辑发送相关的核心函数是HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size);这个函数的逻辑很简单传入串口句柄、缓冲区地址和数据长度HAL 库会设置 DMA 通道的源地址、目标地址、传输长度然后启动 DMA。DMA 搬运完成后会触发发送完成中断HAL 库自动跳到回调函数HAL_UART_TxCpltCallback里让你可以在里面处理后续逻辑。写一个最简单的发送函数uint8_t tx_buf[256]; void uart_send_dma(uint8_t *data, uint16_t len) { memcpy(tx_buf, data, len); HAL_UART_Transmit_DMA(huart1, tx_buf, len); }这里我特意先做了一次memcpy到专用发送缓冲区tx_buf原因是DMA 发送是异步的函数返回后数据才刚开始传输。如果你直接把外部传入的data指针交给 DMA而data指向的缓冲区后续又被别的代码修改那发出去的内容就会变成修改后的内容产生隐性 Bug。使用发送专用缓冲区至少能保证 DMA 在搬运期间看到的是一个稳定副本。3.2 防止连续发送冲突的状态标志HAL_UART_Transmit_DMA有一个让人又爱又恨的机制HAL 库内部有个gState状态机发送期间状态是HAL_UART_STATE_BUSY_TX如果此时再次调用发送函数HAL 会直接返回HAL_BUSY但不报任何错误。很多初学者在这里会卡壳连续调用两次发送第二次没反应。解决办法是在自己的代码里用标志位管理发送“忙”状态volatile uint8_t uart1_tx_busy 0; void uart_send_dma(uint8_t *data, uint16_t len) { while (uart1_tx_busy); // 等待上一次发送完成 memcpy(tx_buf, data, len); uart1_tx_busy 1; HAL_UART_Transmit_DMA(huart1, tx_buf, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1_tx_busy 0; } }回调函数里把忙标志位清零发送函数开头等待标志位为 0。这样即使主循环里频繁调用发送函数也不会出现因为 HAL 的gState还没复位而吞掉数据的情况。3.3 一个可以直接抄的发送框架实际项目中我通常把发送函数封装成支持可变长参数的形式方便直接当 printf 用#include stdarg.h #include stdio.h uint8_t uart1_tx_buf[512]; volatile uint8_t uart1_tx_busy 0; void uart1_send_dma_printf(const char *fmt, ...) { va_list args; va_start(args, fmt); uint16_t len vsnprintf((char *)uart1_tx_buf, sizeof(uart1_tx_buf), fmt, args); va_end(args); if (len sizeof(uart1_tx_buf)) len sizeof(uart1_tx_buf) - 1; while (uart1_tx_busy); uart1_tx_busy 1; HAL_UART_Transmit_DMA(huart1, uart1_tx_buf, len); }这个封装的好处是从此主代码里可以直接uart1_send_dma_printf(adc:%d\r\n, adc_val);发日志而完全不用关心底层是 DMA 还是阻塞。配合uart1_tx_busy使用即使中断里想发送数据只要注意别在中断里长时间while等待即可。有一点需要提醒不建议在中断上下文里调用带while (uart1_tx_busy)的版本。如果 DMA 发送卡死这个 while 会直接死循环中断永远出不来。更好的做法是中断里只置一个“想发数据”标志位真正发送放在主循环处理。4. 串口接收DMA不定长数据就得靠空闲中断来收尾发送 DMA 相对简单接收 DMA 才最容易让人崩溃。很多人第一次用HAL_UART_Receive_DMA都会发现一个诡异现象接收缓冲区的数据确实在更新但你不知道“这一帧数据什么时候结束”不知道数据长度也不知道该什么时候处理。4.1 接收DMA的配置选择Normal和Circular怎么选CubeMX 中接收 DMA 模式有两种Normal 和 Circular循环模式。Normal 模式下DMA 一旦把Size设定个数的字节搬完就停止工作必须重新调用HAL_UART_Receive_DMA才能继续接收。这意味着如果你定义了 256 字节的缓冲区DMA 必须收满 256 字节才触发接收完成中断在这之前即使对方发来 10 个字节DMA 也只会默默把数据放进缓冲区不给你任何通知。Circular 模式下DMA 搬运完Size个字节后计数器自动回绕继续从缓冲区头部重新搬运无限循环运行。配合串口空闲中断我们就可以做到“不确定数据长度也能知道一帧数据什么时候结束”。结论做不定长数据接收RX 建议选 Circular。做定长数据块接收如接收一条固定格式指令、OTA 分包Normal 模式更直观收满一组就触发一次回调。4.2 空闲中断的触发时机与处理思路串口空闲中断IDLE Interrupt的触发时机是当串口接收完一帧数据后接收线上出现一个字节时间的空闲电平。也就是说对方发完最后一个字节后总线上持续一个字节周期没有新数据硬件就认为这一帧收完了触发 IDLE 事件。这个机制天然适合处理不定长协议帧数据来的时候我就在 DMA 循环模式里继续收一旦对方停顿一整个字节时间IDLE 就告诉我们“可以统计这次收了多长了”。使用 H A L 库时CubeMX 默认不会使能 IDLE 中断需要手动开启__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);开启后USART1_IRQHandler里就会收到该中断事件。4.3 接收完整例程与长度计算下面是一个完整的不定长接收示例基于 F103 HAL 库缓冲区大小设 256DMA 使用 Circular 模式串口使能 IDLE 中断。#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len 0; void uart1_rx_start(void) { HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_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)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算本次接收到的数据长度 uint16_t cur_cnt __HAL_DMA_GET_COUNTER(hdma_usart1_rx); rx_len RX_BUF_SIZE - cur_cnt; // 数据位于 rx_buf[0] ~ rx_buf[rx_len - 1] } }这里有个重要细节__HAL_DMA_GET_COUNTER拿到的是 DMA 还剩下多少字节没搬用RX_BUF_SIZE减去它就是 DMA 已经搬进来的字节数。比如 DMA 计数是 246说明已经搬了 10 个字节进来rx_len就是 10。在 Circular 模式下数据是循环写入缓冲区的如果帧很长跨越了缓冲区尾部并回绕到头部处理逻辑就会变得复杂。最稳妥的做法是接收缓冲区开得足够大协议帧最大长度远小于缓冲区这样即使回绕也很容易处理。另一种方案是使用 DMA 的半传输中断 完整传输中断打配合读两个半缓冲区实现双缓冲处理。这些偏进阶我后面再详细写。产生 IDLE 中断后如果你希望新的一帧数据从头开始存放而不是覆盖上一帧的残留位置可以在HAL_UART_RxCpltCallbackDMA 到达缓冲区末尾时触发中重新启动接收void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 清零缓冲区或做必要处理 memset(rx_buf, 0, RX_BUF_SIZE); } }不过要注意HAL_UART_RxCpltCallback在 Circular 模式下本身就会被频繁调用每到一次缓冲区边界就触发不宜在里面做耗时操作置个标志位或清空缓冲区就足够了。5. 实战中高频出现的几个坑以及我是怎么排查的这篇文章如果只讲配置和调用其实网上已经很多了。真正有价值的是那些让你调试两三天才找到答案的坑。下面的内容全部来自我实际项目里的排查经历每一条都有血有泪。5.1 第二次DMA发送失败TC标志被卡住现象第一次调用HAL_UART_Transmit_DMA发送正常紧接着第二次调用同一函数数据发不出去或者发了几秒后又卡死。用调试器看HAL_UART_Transmit_DMA返回值是HAL_BUSY。根因HAL 库判断串口是否“忙”的标准之一是上次发送的 TC发送完成标志是否被处理。第一次发送完成后如果没有清除 TC 标志或等待其置位HAL 的状态机可能没有回到就绪状态。解决发送完成回调里清除 TC 标志void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_TC); uart1_tx_busy 0; } }如果你的回调里没有清除可以考虑在启动下一次发送前主动调用__HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_TC)。有些版本 HAL 库已经在内部处理了但为了兼容性我还是建议大家显式清一次。5.2 DMA接收计数停摆收满一次就“死”了现象使用 Normal 模式接收 DMA第一次收满R X_BUF_SIZE个字节后触发回调但之后再串口发数据程序却毫无反应。__HAL_DMA_GET_COUNTER读取到的值始终等于RX_BUF_SIZE不动了。根因Normal 模式下 DMA 计数器减到 0 后通道自动关闭。HAL 库的HAL_UART_Receive_DMA是一次性启动DMA 停止后自然不会再搬运任何数据。解决在HAL_UART_RxCpltCallback中重新启动接收void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } }这也是为什么接收不定长数据我强烈建议用 Circular 模式的原因——少一个重新启动的环节代码更不容易出问题。5.3 H7系列Cache导致的数据不一致现象在 STM32H750/H743 上使用 DMA 接收串口数据接收缓冲区里的值看起来是“旧”的明明物理上已经收到了新数据CPU 读出来的却是老的缓存值。发送场景则相反CPU 向缓冲区写入有效数据后启动 DMADMA 读出来的却是旧数据。根因H7 内核带有 D-Cache而 DMA 访问内存时不经过 Cache。CPU 读到的数据优先命中 Cache 中的旧内容DMA 写入的新数据没有同步到 CacheCPU 写入的数据如果还在 Cache 里没有回写内存DMA 也看不到。解决在读取 DMA 接收数据之前先失效对应地址范围的 CacheSCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, rx_len);在启动 DMA 发送之前将待发送缓冲区写回内存SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);更彻底的做法是在 CubeMX 中将 DMA 缓冲区所在的 MPU 区域配置为不可缓存Device或Strongly Ordered。我实际项目中用SCB_InvalidateDCache_by_Addr就已经足够解决问题优先推荐。这里特别提醒如果缓冲区定义在 DTCM RAM 中那更麻烦因为 DTCM 是内核私有内存DMA 根本访问不到CubeMX 配置时最好把DMA buffer定向到 SRAM如 AXI SRAM 或 SRAM1/2/3。如果你无处下手把缓冲区数组用__attribute__((section(.ARM.__at_0x24000000)))指定到 AXI SRAM 是比较省事的方式。5.4 波特率被时钟配置“偷走”串口乱码的潜在原因现象DMA 收发都配好了但串口数据偶发乱码或者完全对不上。用逻辑分析仪抓波形发现实际波特率和预期差距很大。根因CubeMX 里填入的外部晶振频率和板子上实际的晶振型号不一致。例如开发板实际是 25MHz 晶振但 CubeMX 的HSE Value还是默认的 8MHz或者反过来。这样系统时钟和 APB 外设时钟都会按错误的输入频率计算最终导致串口波特率和配置值偏差过大。解决在RCC High Speed Clock (HSE)里填入板子实际晶振频率再检查Clock Configuration图确认APB1/APB2实际频率符合预期。很多串口乱码问题的根子不在代码而在时钟树配置。我通常会在用 DDR 或 USB 之前先确认所有外设时钟是否符合预期省得后面排查成本翻倍。5.5 常见问题速查表现象可能原因排查方向第二次 DMA 发送失败TC 标志未清除 / HAL 状态机忙碌检查是否为 HAL_BUSY回调中清除 TC 标志接收不到数据DMA 未启动 / Normal 模式计数到 0 停止确认HAL_UART_Receive_DMA已调用dmarx挂在串口句柄上收到的数据全部相同/覆盖内存地址自增未打开检查MemInc是否为DMA_MINC_ENABLE偶发乱码晶振频率错误/APB 时钟不准核对 HSE、PLL 配置用逻辑分析仪看实际波特率DMA 数据全 0 / 旧数据D-Cache 缓存未同步使用SCB_CleanDCache_by_Addr/SCB_InvalidateDCache_by_Addr一收满就卡死Normal 模式被停止重启动HAL_UART_Receive_DMA6. DMA不止用于串口ADC、PWM、SPI的顺带一提DMA 在串口上的价值已经很明显其他外设其实更依赖 DMA比如 ADC 多通道采集、PWM 灯带驱动、SPI 高速数据搬运。这些场景的原理和串口大同小异但在 CubeMX 里的配置细节各有不同。6.1 ADC多通道采集与DMA的配合多通道 ADC 如果不开 DMA规则组转换完成后你就得自己在中断里读ADCx-DR多个通道的数据还要手动拼接。开了 DMA 后ADC 每转换完一个通道DMA 会把数据自动搬运到内存数组对应位置转换完成中断里直接拿数组就能用。CubeMX 里配置 ADC 开启扫描模式Scan ModeDMA Settings 里添加 ADC 的 DMA 请求Mode 选择 Circular。生成代码后注意定义缓冲区的类型要和ADC_Resolution匹配比如 12 位分辨率时数据寄存器是 16 位的缓冲区就该用uint16_t数组。uint16_t adc_values[8]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_values, 8);之后就可以不断从adc_values里读更新的数据。这里面有个细节ADC 的 DMA 模式和串口不同几乎总是用 Circular因为 ADC 一直在转换DMA 需要持续搬运。回调函数HAL_ADC_ConvCpltCallback在每次 DMA 搬完一轮时触发适合做数据处理或置标志位。6.2 PWMDMA驱动灯带驱动 WS2812 这类单总线灯带时PWM DMA 是一个非常经典的组合。原理是用定时器输出一路 PWM频率约 800kHz。通过 DMA 修改定时器的比较寄存器CCR实现不同占空比的数据位编码。DMA 每传一个数据CCR 就切换一次占空比从而在 PWM 引脚上生成一串符合协议的数字信号。CubeMX 里只需要配置定时器 PWM Generation在 DMA Settings 里添加TIMx_CHx对应的 DMA 请求然后准备一个显示缓冲数组启动 DMA 传输即可。这种玩法的优点是刷新灯带不占 CPU缺点是缓冲区大小要和灯珠数量精确对齐而且 DMA 一次性传完整个数组灯带数量大了之后内存占用比较可观。我测试过 60 颗灯珠、30fps 刷新率的情况下CPU 占用率几乎为零效果比 GPIO 翻转方案舒服太多。6.3 SPI、CAN选择DMA还是中断SPI 通信和串口类似数据量小的时候用HAL_SPI_Transmit_IT即可数据量大、频率高建议开 DMA。SPI 的 DMA 和串口有个差异SPI 是全双工往往需要 TX、RX 两个 DMA 同时工作CubeMX 里直接添加两个 DMA 请求就行。CAN 总线则不一样。CAN 协议本身就有 FIFO 硬件缓存通常收一帧 8 字节数据用中断接收完全足够不需要 DMA。只有在使用 CAN FD、数据量非常大的情况下才值得考虑 DMA。所以不要盲目什么外设都用 DMA中断资源够用且数据量小的情况下中断反而更简单可靠。DMA 用到现在我感觉它最大的价值不是“跑得快”而是“把 CPU 从机械重复的数据搬家中解放出来”。当你开始把串口收发、ADC 采集这些重活交给 DMA 之后再去想产品还有哪些功能可以加、哪些逻辑还能优化思路会完全不一样。建议你把上面的 Circular 接收例程先跑通再自己改成数据帧解析、命令分发体会一下“串口数据自己就准备好了我只管处理”的感觉基本就出师了。