简介:面向STM32F103嵌入式开发者的USART3 DMA通信实例包,聚焦标准库环境下串口高效收发与CPU占用优化,适合已掌握基础外设、希望进阶DMA传输的开发者参考。压缩包共171个文件,约3.41MB,以C源文件、H头文件为主,辅以汇编启动文件、编译中间文件及少量工程配置与说明文档,覆盖工程搭建、外设初始化到运行输出的完整链路。已有3315人学习下载,具备一定参考热度。内容围绕DMA1通道与USART3的协同配置展开,包含发送与接收方向、传输量、循环模式及完成与错误中断处理,并给出缓冲区组织与软件流程示例;读者可据此搭建可验证的串口DMA收发工程,对照中断回调与状态更新排查常见问题,也可作为其他USART口DMA改造的模板。
1. 从一次半帧错位说起:STM32F103 的 USART3 + DMA 到底救的是什么场
去年帮人看一块 STM32F103 的板子,USART3 跑 115200,上位机每 20 ms 下发一帧 64 字节,从站用接收中断逐字节存数组,跑十几分钟就冒出半帧、错位帧。后来换成 STM32F103 的 USART3 走 DMA 搬运,配标准库(StdPeriph 3.5.0)直接操作外设寄存器,问题消失。这套写法的核心价值只有一句:CPU 不要在搬字节上浪费时间——发送不用死等 TC,接收不用每字节进一次中断,主循环该干什么干什么。
它适合谁?已经跑通串口、手上是标准库工程、正在被高波特率或不定长帧折腾的从业者。下面按我实际的落地顺序拆:先把硬件链路钉死,再分别做发送端和接收端,然后是踩坑清单,最后是压测验收。
2. 先把硬件链路钉死:USART3 的引脚、DMA 请求通道与工程骨架
动手写代码之前,有两件事必须先确认,否则后面所有调试都是在黑匣子里瞎猜:USART3 的 DMA 请求到底挂在哪个通道上,以及这几个外设分别由哪条总线供时钟。这两条搞错,代码写得再漂亮也是一个字节都动不了。
2.1 USART3 为什么只能挂 DMA1 的通道 2 和通道 3
STM32F103 的 DMA 请求映射是硬件固化在芯片里的,不是软件可选。你在 DMA_InitStructure 里填的是通道号,通道和请求源之间是硬连线。USART3_TX 固定接 DMA1 的通道 2,USART3_RX 固定接 DMA1 的通道 3,这条关系写在参考手册的 DMA1 请求映射表里。
| DMA1 通道 | 可服务的请求(节选) | 本方案用途 |
|---|---|---|
| Channel1 | ADC1、TIM2_CH3、TIM4_CH1 | 不用 |
| Channel2 | SPI1_RX、USART3_TX、TIM1_CH1、TIM2_UP、TIM3_CH3 | USART3 发送 |
| Channel3 | SPI1_TX、USART3_RX、TIM1_CH2、TIM3_UP、TIM3_CH4 | USART3 接收 |
| Channel4 | SPI2_RX、USART1_TX、I2C2_TX | 不用 |
| Channel5 | SPI2_TX、USART1_RX、I2C2_RX | 不用 |
| Channel6 | USART2_RX、I2C1_TX | 不用 |
| Channel7 | USART2_TX、I2C1_RX | 不用 |
这张表还有个隐藏信息:通道 2 和通道 3 同时被 SPI1 和 USART3 共享。也就是说,如果你的板子上 SPI1 也在跑,而且用了 DMA,那它和 USART3 就是抢同一对通道,两者不能同时用 DMA。我见过一个项目就是这么翻车的:SPI1 刷屏刷得好好的,一开 USART3 的 DMA 接收,屏就不刷新了。所以选型时先看整块板子的 DMA 通道分配,别只看眼前这一个外设。
另一个常被忽略的点是 USART3 挂在哪条总线。USART1 在 APB2,USART2 和 USART3 在 APB1,所以开时钟是 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE),而 DMA1 挂在 AHB 上,走的是 RCC_AHBPeriphClockCmd。三条不同的时钟线,漏一条就全盘不动。
2.2 时钟、GPIO 与 USART3 模式配置的最小闭环
引脚这块:USART3 默认映射在 PB10(TX)和 PB11(RX)上,重映射后才到 PD8/PD9。多数小板子用默认映射就够了,PB10 配复用推挽输出,PB11 配浮空输入或上拉输入。这里有个细节——PB11 配浮空输入时,如果外部没有上拉电阻,线上悬空期间会收到一堆 0x00 的乱码,尤其是在对端还没上电的时候。我一般直接配 GPIO_Mode_IPU(上拉输入),能省掉一批“上电瞬间收到脏数据”的投诉。
/* 时钟、GPIO、USART3 一次性配好,注意三条时钟线 */ static void USART3_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); /* USART3 在 APB1 */ RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* DMA1 在 AHB */ /* PB10 -> USART3_TX,复用推挽 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); /* PB11 -> USART3_RX,上拉输入,避免对端未上电时线上悬空 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOB, &GPIO_InitStructure); } static void USART3_Mode_Config(uint32_t baud) { USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = baud; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART3, &USART_InitStructure); USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); /* 接收侧只开 IDLE */ USART_DMACmd(USART3, USART_DMAReq_Tx | USART_DMAReq_Rx, ENABLE); /* 关键一步 */ USART_Cmd(USART3, ENABLE); }这段里最容易漏的是 USART_DMACmd 那一行。它对应 CR3 寄存器里的 DMAT 和 DMAR 两个位,作用是告诉 USART:“你的 DR 有数据了别去找 CPU,去找 DMA”。这一位不开,DMA 通道配得再完美,也是干瞪眼——因为 DMA 请求根本没发出来。我统计过自己踩过的 USART+DMA 类问题,这一条占了三成以上。
还有一点:接收侧我只开了 IDLE 中断,没开 RXNE 中断。因为 RXNE 一旦开了,DMA 每搬一个字节硬件都会把 RXNE 置起来,如果此时 CPU 进中断读 DR,就会和 DMA 抢同一个字节,丢数据丢得莫名其妙。要用 DMA 收,就别开 RXNE 中断。
2.3 工程骨架:把 DMA 收发切成三层文件
顺手说一下文件组织,这决定了后期改起来是五分钟还是五小时。我的做法是三层:usart3_dma.c 只放硬件相关(GPIO、USART、DMA 通道、NVIC),bsp_uart.c 放缓冲区和收发接口,proto_parse.c 放协议状态机。三层之间只通过三个函数交互:
| 接口 | 方向 | 说明 |
|---|---|---|
USART3_DMA_Send(buf, len) | 上层调下层 | 非阻塞,缓冲区忙就返回失败 |
on_byte_stream(buf, len) | 下层调上层 | IDLE 中断里把本帧原始字节交出去 |
proto_feed(p, n) | 解析层 | 逐字节喂状态机,产出完整帧 |
这么切的好处是:换波特率、换 DMA 通道、换协议,改动都局限在一层里。我见过太多工程把 DMA 配置、协议解析、业务逻辑全塞进 main.c,半年后自己都不敢动。
顺便把 NVIC 也配好,优先级数字后面第 5 章还会用到:
static void NVIC_Config(void) { NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); /* 2 位抢占 + 2 位子优先级 */ NVIC_InitStructure.NVIC_IRQChannel = USART3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = DMA1_Channel2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_Init(&NVIC_InitStructure); }USART3_IRQn 和 DMA1_Channel2_IRQn 在 NVIC 里是两条独立的线,抢占优先级给成一样(都是 1),子优先级错开,保证 DMA 发送完成中断不会打断 IDLE 中断里正在跑的取帧逻辑。
3. 发送端:DMA 搬运的完整写法与三个必踩的参数
发送端比接收端简单,但“简单”不等于“不会错”。我调试过的发送侧问题,基本都集中在两个地方:缓冲区被提前复用,和 TC 判据用错。这两个都属于那种现象很玄学、原因很朴素的类型。
3.1 发送缓冲区为什么必须是静态的、有上限的
DMA 是异步的。你调用完发送函数返回了,DMA 还在后台一个字节一个字节往 USART3->DR 里灌。如果这时你把传进来的指针指向的栈变量释放了,或者往同一个 buffer 里写了新内容,DMA 搬出去的就会是半截旧数据混半截新数据——这种错误在逻辑分析仪上看起来像是“数据被随机篡改”,非常难查。
所以我的规矩是:DMA 发送缓冲区一律用文件作用域的静态数组,调用方传进来的数据先 memcpy 进去,再启动 DMA。代价是一次拷贝,115200 波特率下拷 64 字节大概 1 微秒都不到,完全可以接受。
缓冲区大小怎么定?按最坏情况算。115200 波特率、8N1,理论字节率约 11520 B/s,如果主循环最长可能 20 ms 才喂一次数据,那这段时间最多要发出去 230 字节,缓冲区至少给 256。我一般直接给 256 或 512,几十字节的 RAM 换一份安心。
| 波特率 | 理论字节率 | 20 ms 内最大字节数 | 建议发送缓冲 |
|---|---|---|---|
| 9600 | ~960 B/s | ~19 | 64 |
| 115200 | ~11520 B/s | ~230 | 256 |
| 460800 | ~46080 B/s | ~922 | 1024 |
3.2 DMA1_Channel2 逐项配置与 CNDTR 的改写时机
先看配置。这里用的是正常模式(Normal),不是循环模式,因为发送是“来一笔发一笔”,不存在自动重来。
#define USART3_TX_BUF_SIZE 256 static uint8_t s_tx_buf[USART3_TX_BUF_SIZE]; static volatile uint8_t s_tx_busy = 0; /* 1 = DMA 正在搬,别动缓冲区 */ static void USART3_TX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel2); /* 先复位,清掉可能的残留配置 */ DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)s_tx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; /* 内存 -> 外设 */ DMA_InitStructure.DMA_BufferSize = 0; /* 每次发送时重设 */ 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_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel2, &DMA_InitStructure); DMA_ITConfig(DMA1_Channel2, DMA_IT_TC, ENABLE); /* 只开传输完成中断 */ /* 通道先不使能,等第一笔数据来了再开 */ }逐项说明几个关键参数。DMA_PeripheralBaseAddr 填的是 &USART3->DR,注意是 DR 的地址而不是 SR 的,写错到 SR 上会变成往外设状态寄存器写数据,结果就是什么都不发。DMA_DIR 填 DMA_DIR_PeripheralDST 表示方向是内存到外设,接收端正好相反。DMA_PeripheralInc 必须是 Disable,因为 USART3 只有一个 DR 寄存器,地址不能递增;DMA_MemoryInc 必须是 Enable,否则永远只搬第一个字节。两个 DataSize 都填 Byte,因为 USART 配置的是 8 位字长,填 HalfWord 会按 16 位搬,发出去的数据全是错位的。
然后是最容易被写错的地方——CNDTR 的改写。标准库没有为 CNDTR 提供写接口,只能直接操作寄存器,而写 CNDTR 的前提是通道已经关闭:
uint8_t USART3_DMA_Send(const uint8_t *data, uint16_t len) { if (len == 0 || len > USART3_TX_BUF_SIZE) return 1; if (s_tx_busy) return 2; /* 上一笔没发完,直接拒绝 */ memcpy(s_tx_buf, data, len); s_tx_busy = 1; DMA_Cmd(DMA1_Channel2, DISABLE); /* 改 CNDTR 前必须关通道 */ DMA1_Channel2->CNDTR = len; /* 传输个数 = 本帧字节数 */ DMA1_Channel2->CMAR = (uint32_t)s_tx_buf; /* 内存地址也重设一遍,防止被改过 */ DMA_ClearFlag(DMA1_FLAG_TC2 | DMA1_FLAG_GL2); DMA_Cmd(DMA1_Channel2, ENABLE); return 0; }在通道使能状态下改 CNDTR,DMA 的行为是未定义的——我实测过,改动有时生效有时不生效,取决于当前 DMA 是否正好在搬运周期里。这种“偶发”最耗时间,所以干脆养成习惯:先 DISABLE,改完再 ENABLE。清标志那一行也不能省,TC2 是上一笔留下的,不清掉的话,使能后 DMA 会立刻进一次完成中断,你会看到发送函数刚返回中断就来了,非常困惑。
3.3 TC 中断里收尾:DMA 的 TC 不等于数据已出线
这是发送侧最大的一个坑,也是我用血泪经验换来的:DMA 的传输完成(TC)只代表最后一字节已经从内存写进了 USART3->DR,不代表这个字节已经从 TX 引脚移出去了。USART 内部还有一个移位寄存器,最后一字节可能要在里面待上整整一个字符时间(115200 下约 87 微秒)。
这个差别在纯数据发送时看不出来,但在两种场景下会立刻翻车:一是发完就切 RS485 的 DE 方向脚,最后半位被截断;二是发完立刻进低功耗或者复位,最后一个字节直接丢。
/* DMA 传输完成中断:只表示内存搬完了 */ void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC2); DMA_Cmd(DMA1_Channel2, DISABLE); s_tx_busy = 0; /* 缓冲区可以复用了,但线上可能还有 1 字节在移 */ } } /* 真正要确认"出线"时,补这一句 */ void USART3_Wait_Last_Byte(void) { while (USART_GetFlagStatus(USART3, USART_FLAG_TC) == RESET); }更彻底的做法是:如果是 RS485 半双工,把方向切换放到 USART 的 TC 中断里做,而不是 DMA 的 TC 中断里。具体就是 USART_ITConfig(USART3, USART_IT_TC, ENABLE),在 USART3_IRQHandler 里判断 USART_IT_TC 再拉低 DE,然后关掉这个中断。多的几行代码,换掉一整类“最后一字节丢失”的问题。
3.4 printf 重定向到 DMA:按行攒、按块发
调试期最方便的事就是把 printf 接到 DMA 上,这样打印日志几乎不占 CPU。但写法有讲究。最朴素的重定向是每来一个字符就启动一次 DMA:
/* 反面教材:每个字节一次 DMA,开销比直接轮询还大 */ int fputc_bad(int ch, FILE *f) { uint8_t c = (uint8_t)ch; USART3_DMA_Send(&c, 1); while (s_tx_busy); return ch; }这个写法有两个问题:一是每字节一次 DMA 配置,寄存器操作开销远超收益;二是如果 printf 里有 %s 打印长字符串,中间任何一次 s_tx_busy 拒绝都会导致日志缺字符。正确姿势是按行攒:
int fputc(int ch, FILE *f) { static uint8_t line[128]; static uint16_t n = 0; if (ch == '\n' || n >= sizeof(line)) { USART3_DMA_Send(line, n); /* 一次 DMA 推一整行 */ while (s_tx_busy) { } /* 调试版阻塞等待,保证日志不乱序 */ n = 0; } if (ch != '\n') line[n++] = (uint8_t)ch; return ch; }注意这里面的 while (s_tx_busy) 是调试期专用的阻塞等待。生产代码不能这么写,因为一旦串口被对端流控卡住,整个主循环就挂死在这里。产品里我一般换成一个 256 字节的发送环形队列加一个后台搬运任务,printf 只负责入队,队列满就丢弃并计数——丢日志总比卡死强。
4. 接收端:IDLE 中断配 DMA 剩余计数,切出不定长帧
接收端是这套方案真正出彩的地方。传统做法是每收一个字节进一次中断,115200 下大约每 87 微秒进一次,主循环稍微干点重活就跟不上。用 DMA 之后,中断次数从“每字节一次”降到“每帧一次”,这个量级差异在带 RTOS 或者跑 LCD 刷新的板子上是能立刻感觉到的。
4.1 循环模式下的写指针反推与回绕处理
接收端我用的是循环模式(Circular),这是关键选型。循环模式下 DMA 搬满缓冲区后会自动从头覆盖,不需要在中断里手动重启 DMA——省掉了“重启瞬间来的字节丢失”这类经典问题。
写指针的推算方式:DMA 的 CNDTR 寄存器里存的是“还剩多少个字节没搬”,所以已经搬了多少 = 缓冲区长度 - 剩余数。
#define USART3_RX_BUF_SIZE 512 static uint8_t s_rx_buf[USART3_RX_BUF_SIZE]; static uint16_t s_rx_last = 0; /* 上一次已经交给上层的写指针位置 */ static void USART3_RX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)s_rx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; /* 外设 -> 内存 */ DMA_InitStructure.DMA_BufferSize = USART3_RX_BUF_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_Channel3, &DMA_InitStructure); DMA_Cmd(DMA1_Channel3, ENABLE); }缓冲区长度怎么定?原则是“最坏情况下两帧之间上层没来得及处理,也不能被覆盖”。115200 下 512 字节相当于 44 毫秒的缓冲,只要上层取帧逻辑不是特别重就够用。如果协议里单帧最长能到 300 字节,那就得给到 1024 以上,留出至少两帧的余量。
4.2 IDLE 中断里清标志的正确顺序
IDLE 中断的触发条件是:RX 线上在收完一个字节后,又保持空闲超过一个字符时间。这正好是“一帧结束”的天然标记,用来切不定长帧再合适不过。
但清标志的顺序有讲究。IDLE 标志的清除方式是“先读 SR,再读 DR”,这两步缺一不可,而且必须先 SR 后 DR。用标准库的 USART_GetFlagStatus + USART_ReceiveData 也是这个序列。我下面直接操作寄存器,看得更清楚:
volatile uint32_t g_ore_cnt = 0; void USART3_IRQHandler(void) { /* 一次读走 SR,IDLE / ORE / RXNE 的状态都在这一下里定格 */ uint16_t sr = USART3->SR; uint16_t dr = USART3->DR; /* 读 DR 完成清标志序列,同时吃掉可能的残留字节 */ (void)dr; if (sr & USART_FLAG_ORE) { /* 溢出:必须清,否则后续接收全废 */ g_ore_cnt++; } if (sr & USART_FLAG_IDLE) { uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel3); /* 剩余可写数 */ uint16_t pos = USART3_RX_BUF_SIZE - remain; /* 当前写指针 */ if (pos != s_rx_last) { uint16_t len = (pos > s_rx_last) ? (uint16_t)(pos - s_rx_last) : (uint16_t)(USART3_RX_BUF_SIZE - s_rx_last + pos); on_byte_stream(s_rx_last, len); s_rx_last = pos; } } }这里 pos 和 s_rx_last 的大小关系必须分两种情况:pos 大于 s_rx_last 说明这一帧没有越过缓冲区尾部,长度直接相减;pos 小于 s_rx_last 说明写指针跨过了 511 回到 0,长度要分成“尾段 + 头段”两截算。这个回绕判断只要漏写,症状就是“每隔一段时间丢一帧”,而且丢的位置跟缓冲区大小有关,非常有规律,但如果不盯着 CNDTR 看,很难想到是这个原因。
4.3 上层取帧:环形缓冲、双缓冲还是直接拷贝
on_byte_stream 拿到的是“环形缓冲区里从 start 开始、长 len 的一段连续区域”。这里有个选择:是直接在中断里解析,还是先拷出来再解析?
我的建议是拷出来。中断里只做 memcpy,把数据搬到一个独立的解析缓冲区,然后置一个标志位,解析在主循环里做。原因是解析状态机里可能有 CRC 计算、可能有 memcpy 到业务结构体、甚至可能触发一次回包,这些都太重,放在中断里会拉长中断响应时间,影响其他中断。
static void on_byte_stream(uint16_t start, uint16_t len) { if (start + len <= USART3_RX_BUF_SIZE) { proto_feed(&s_rx_buf[start], len); /* 没跨尾,一次喂完 */ } else { uint16_t first = USART3_RX_BUF_SIZE - start; proto_feed(&s_rx_buf[start], first); /* 尾段 */ proto_feed(&s_rx_buf[0], len - first); /* 头段 */ } }如果解析确实很轻(比如只是把字节塞进另一个队列),直接喂也完全可以,省一次拷贝。判断标准是:中断里执行时间能不能稳定控制在 10 微秒以内。超了就该挪到主循环。
4.4 一个能直接跑的帧解析状态机
拿到字节流之后需要一个状态机来切帧。我用的这套结构简单、抗干扰,遇到任何异常都能自己回到找帧头的状态,不会卡死:
typedef enum { ST_HEAD1 = 0, ST_HEAD2, ST_LEN, ST_BODY, ST_CRC } parse_state_t; static parse_state_t s_state = ST_HEAD1; static uint8_t s_len = 0; static uint8_t s_idx = 0; static uint8_t s_body[64]; void proto_feed(const uint8_t *p, uint16_t n) { while (n--) { uint8_t b = *p++; switch (s_state) { case ST_HEAD1: if (b == 0x5A) s_state = ST_HEAD2; break; case ST_HEAD2: if (b == 0xA5) s_state = ST_LEN; else if (b != 0x5A) s_state = ST_HEAD1; /* 0x5A 就留在原地重试 */ break; case ST_LEN: if (b == 0 || b > sizeof(s_body)) { s_state = ST_HEAD1; break; } s_len = b; s_idx = 0; s_state = ST_BODY; break; case ST_BODY: s_body[s_idx++] = b; if (s_idx >= s_len) s_state = ST_CRC; break; case ST_CRC: if (crc8(s_body, s_len) == b) proto_dispatch(s_body, s_len); s_state = ST_HEAD1; /* 无论对错都回到找头 */ break; } } }三个细节值得说一下。ST_HEAD2 那一步,如果收到的是 0x5A,不能直接退回 ST_HEAD1,因为它可能就是新帧的第一个头字节,所以我让它留在原地;如果收到既不是 0xA5 也不是 0x5A 的字节,才彻底退回。ST_LEN 里对长度做了上下限检查,防止一个干扰字节把 s_idx 推到数组外面去——这是嵌入式里最常见的
本文还有配套的精品资源,点击获取