简介:面向学习STM32F103C8T6串口通信与设备联调的嵌入式初学者,这份完整工程聚焦USART2带奇偶校验的配置与验证,覆盖串口2时钟使能、PA2和PA3复用引脚、波特率与数据位设置,以及通过控制寄存器使能位切换奇校验和偶校验,并给出中断或DMA扩展思路。压缩包共78个文件,以34个头文件和33个源文件为主体,辅以Keil工程、启动汇编、HEX产物和说明文档,整体约308KB,目录按硬件驱动、系统配置、内核启动、标准外设库与用户代码组织,便于对照库函数理解结构。已有2446人学习下载。读者可获得可直接编译的串口2奇偶校验代码、板级驱动组织方式与收发调试方法,结合按键、LED、延时等模块还能梳理外设初始化顺序,适合快速搭建验证环境并排查校验位设置问题。
1. 一块板子上的 8E1:串口2 配奇偶校验,为什么总卡在第九位
去年帮人看一块 F103 的板子,传感器手册上白纸黑字写着“115200, 8E1”,开发者照着串口1 的代码改了个外设编号,波特率对了、引脚对了、时钟也开了,结果收到的每一个字节最高位都是 0,偶发还能收到 0x00。换到串口2 上重试,故障一模一样。问题不在硬件,也不在杜邦线,而在他始终把奇偶校验当成一个“勾选项”,而 STM32F103 的 USART 会把校验位塞进数据字的最高位,整帧从 8 位数据变成 9 位。只要USART_CR1里那个 M 位没跟着一起改,第 8 个数据位就被校验逻辑顶掉,收到的自然全是残缺数据。这篇就把串口2 在 8E1、8O1 下的寄存器关系、两套库的写法、PE 标志处理和几个高频翻车点一次讲透,给的是能直接照着改的代码和参数。
2. 拆开 USART_CR1:M 位、PCE 位和 PS 位谁管谁
2.1 校验位是“占位”占出来的,不是加出来的
很多人对奇偶校验的心智模型是“在数据后面追加一位”,这个理解在软件协议里成立,在 STM32F103 的 USART 上不成立。F103 参考手册对帧结构的描述是:起始位、数据位、校验位、停止位,其中校验位被放在数据字的最高位位置。也就是说,硬件不是往外“追加”第 9 位,而是把你原本的 8 位数据挤出最高位,让校验位坐进去。
这个设计直接决定了寄存器配置:USART_CR1的M 位(字长)必须从 0 改成 1,让硬件知道这一帧有 9 位;PCE 位置 1 打开校验;PS 位决定 0 是偶校验还是 1 是奇校验。三个位必须联动,缺一个都会出问题。
| M 位 | PCE 位 | PS 位 | 硬件实际帧结构 | 结果 |
|---|---|---|---|---|
| 0 | 0 | x | 1 起始 + 8 数据 + 1 停止 | 常规 8N1,正常 |
| 1 | 0 | x | 1 起始 + 9 数据 + 1 停止 | 真 9 位数据模式,无校验 |
| 0 | 1 | 0/1 | 1 起始 + 7 数据 + 1 校验 + 1 停止 | 最高数据位被校验位顶掉,收发全错 |
| 1 | 1 | 0 | 1 起始 + 8 数据 + 偶校验 + 1 停止 | 8E1,正确 |
| 1 | 1 | 1 | 1 起始 + 8 数据 + 奇校验 + 1 停止 | 8O1,正确 |
表里第三行就是那个血泪场景。你只开了 PCE 没改 M,硬件不会报错,也不会进 HardFault,它只是安静地把你的 8 位数据截成 7 位,再接一个校验位发出去。对端如果开了校验,收到的就是校验错误;对端如果没开校验,收到的就是被砍掉高位的数据。
注意:8E1 里的“8”指的是有效数据位 8 位,不是寄存器字长。字长要写 9,这是两回事,混淆这两个概念是这类问题最常见的来源。
2.2 帧格式算一遍:8E1 到底发了几个位
把一帧摊开数,8E1 是 1 起始位 + 8 数据位 + 1 校验位 + 1 停止位,共 11 位。这个数字决定了三件事:一是逻辑分析仪解码时要设成 11 位、LSB 先出;二是波特率相同时,8E1 的有效吞吐比 8N1 低约 9%;三是接收方如果按 8 位数据解析,会整体错位一位。
拿一个具体字节验证。假设要发 0x5A,二进制0101 1010,其中 1 的个数是 4 个。
- 偶校验:1 的个数要保持为偶数,所以校验位补 0,线上顺序(LSB 先)为
0 1 0 1 1 0 1 0 0 1(起始 + 8 数据 + 校验 + 停止)。 - 奇校验:1 的个数要保持为奇数,校验位补 1,帧尾就变成
... 1 1。
这个手算过程看着简单,但它是排查现场问题最有效的手段。逻辑分析仪抓一帧,按 11 位解出来,对着算一遍校验位,就能立刻判断是发送端算错了、还是接收端把 M 位配错了、还是波特率偏差导致采样点漂了。
还有个细节值得单拎出来:在 9 位字长下读USART_DR,寄存器低 9 位有效,bit8 是校验位,bit7~bit0 是数据。所以读出来之后要做一次& 0xFF才是真正的业务数据。标准外设库的USART_ReceiveData()返回uint16_t,很多人直接强转uint8_t或者直接参与运算,一旦数据是 0x80 以上,就会带着校验位一起算进去。
2.3 串口2 的时钟与引脚,先确认底座没问题
在动校验位之前,先把串口2 的底座确认一遍,不然改了 M 位也白改。STM32F103 上 USART2 挂在 APB1,最大 36MHz(标准型号 APB1 不分频时),而 USART1 挂在 APB2,72MHz。这个差异直接影响波特率寄存器的量化误差。
USART_BRR的格式是高 12 位整数部分(DIV_Mantissa),低 4 位小数部分(DIV_Fraction),小数只有 4 位精度。以 115200 为例,36MHz 下:
USARTDIV = 36 000 000 / (16 × 115200) = 19.53125 整数部分 19 → 0x13 小数部分 0.53125 × 16 = 8.5 → 四舍五入为 9 → 0x9 BRR = 0x139 实际波特率 = 36e6 / (16 × (0x139 / 16)) = 36e6 / (16 × 19.5625) = 115018 误差 ≈ -0.16%0.16% 在 8N1 下基本无感,但结合奇偶校验之后,接收端的判错窗口会变窄,遇到晶振本身偏差 1%~2% 的情况就容易间歇性报 PE。常见做法是优先选 9600、19200、115200 这类能整除得比较干净的波特率,或者在系统允许时把 APB1 拉到 36MHz 满速。
引脚这边,USART2 默认复用是 PA2(TX)、PA3(RX)。标准外设库里 TX 要配GPIO_Mode_AF_PP,RX 配GPIO_Mode_IN_FLOATING或GPIO_Mode_IPU。使用重映射时才需要打开 AFIO 时钟(RCC_APB2Periph_AFIO),HAL 库下则通过__HAL_AFIO_REMAP_USART2_ENABLE()之类宏完成。不少人的代码里 AFIO 时钟是“习惯性打开”,这本身无害,但会掩盖重映射没配对的问题。
/* 串口2 底座配置:时钟 + 引脚,标准外设库写法 */ static void USART2_GPIO_Clock_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; /* USART2 在 APB1,GPIOA 在 APB2,两个时钟都要开 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); /* PA2 = USART2_TX,复用推挽输出,50MHz 足够 115200 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* PA3 = USART2_RX,浮空输入;线上有干扰时改 GPIO_Mode_IPU */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); }这段函数只干一件事:把时钟和引脚摆正。逻辑说明上,RCC_APB1PeriphClockCmd那行如果漏了,USART_Init()写进去的配置全是空的,现象是引脚一动不动;RCC_APB2PeriphClockCmd漏了 GPIOA,则表现更隐蔽——TX 脚没有推挽驱动能力,示波器上看是一个弱上拉或悬浮电平,像“信号幅度不够”。参数上,GPIO_Speed选 50MHz 是留余量,不是必须;RX 的浮空与上拉取决于外部是否有上拉电阻和线缆长度,长线建议加上拉,短距离板内直连浮空即可。
3. 两套库的落地写法:8E1 和 8O1 各配一次就看明白
3.1 标准外设库:四行结构体里最容易被忽略的一行
标准外设库的配置逻辑很直白,问题在于USART_WordLength_9b这个枚举名字容易让人犹豫——“我要的是 8 位数据,凭什么写 9b”。回到 2.1 的表,答案就清楚了:因为校验位占了第 9 位。
/* 串口2 完整初始化:8 数据位 + 偶校验 + 1 停止位,115200 */ void USART2_Config_8E1(void) { USART_InitTypeDef USART_InitStructure; USART2_GPIO_Clock_Init(); /* 见 2.3,先摆好时钟和引脚 */ USART_InitStructure.USART_BaudRate = 115200; /* 关键行:开校验必须配 9b,否则第 8 数据位会被校验位顶掉 */ USART_InitStructure.USART_WordLength = USART_WordLength_9b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_Even; /* 奇校验改成 USART_Parity_Odd */ USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &USART_InitStructure); /* 接收使能前先清一次状态,避免上电残留的 PE/ORE 卡住首帧 */ USART_ClearFlag(USART2, USART_FLAG_PE | USART_FLAG_ORE | USART_FLAG_FE | USART_FLAG_NE); USART_Cmd(USART2, ENABLE); }逻辑说明:USART_Init()内部会把USART_WordLength、USART_Parity逐位写进CR1,其中USART_WordLength_9b对应USART_CR1_M,USART_Parity_Even对应USART_CR1_PCE,USART_Parity_Odd对应USART_CR1_PCE | USART_CR1_PS。三个枚举值是位或关系,所以结构体成员之间不是独立的,Parity一改,WordLength必须跟着改。
参数说明:USART_BaudRate传入 115200 后,库函数会根据当前 APB1 频率算 BRR,注意SystemInit()里 APB1 的分频系数,如果之前把它配成 2 分频,实际波特率会差一倍。停止位在 8E1/8O1 场景里基本都是USART_StopBits_1,只有对端明确要求 2 位停止位时才改。硬件流控保持关闭,除非板子上真的接了 RTS/CTS。
收发函数也要注意宽度。USART_SendData(USART2, (uint8_t)data)传 8 位即可,硬件会自动算校验位补到第 9 位;USART_ReceiveData(USART2)返回uint16_t,取低 8 位才是数据。
/* 收:读出来先取低 8 位,bit8 是校验位,不能进业务逻辑 */ uint8_t USART2_ReadByte(void) { while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) == RESET) { /* 现场调试时在这里加超时计数,量产版必须加 */ } return (uint8_t)(USART_ReceiveData(USART2) & 0x00FF); } /* 发:传 8 位数据,硬件负责补校验位 */ void USART2_WriteByte(uint8_t ch) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET) { /* 等发送寄存器空 */ } USART_SendData(USART2, ch); }这两段是配套的最小实现。第一段里那个while死等是新手代码,实际项目里要加超时,否则一根断线能让主循环卡死;第二段同理,等待TXE而不是TC,除非你需要在最后一个字节完全移出引脚后再关闭收发器。
3.2 HAL 库:WordLength 与 Parity 的联动陷阱
CubeMX 生成的 HAL 代码有个特点:它不会阻止你配出互相矛盾的组合。HAL_UART_Init()内部的UART_SetConfig()只是把WordLength和Parity两个字段直接或进CR1,做不做一致性校验?不做。也就是说,你在 CubeMX 里把 Parity 选成 Even、WordLength 保持 8B,代码能生成、能编译、能跑,就是数据全错。
/* HAL 版本:串口2 配 8E1,重点是 WordLength 与 Parity 必须联动 */ UART_HandleTypeDef huart2; void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 115200; huart2.Init.WordLength = UART_WORDLENGTH_9B; /* 不是 8B */ huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_EVEN; /* 奇校验写 UART_PARITY_ODD */ huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart2.Init.OverSampling = UART_OVERSAMPLING_16; /* F103 上只有 16 倍过采样 */ if (HAL_UART_Init(&huart2) != HAL_OK) { Error_Handler(); } }逻辑说明:OverSampling在 F103 上只能是 16,参考手册里虽然提到 8 倍过采样,但 HAL 的 F1 实现和多数封装库并不支持,写成 8 反而会导致HAL_UART_Init返回错误或波特率算错。HAL_UART_Init内部会调用HAL_UART_MspInit,GPIO 和时钟的初始化要写在那个回调里,不要塞进MX_USART2_UART_Init之前,否则 CubeMX 重新生成代码时会被覆盖掉。
参数说明:BaudRate支持任意整数,HAL 会算最接近的 BRR;WordLength与Parity的合法组合只有三种——8B + NONE、9B + EVEN、9B + ODD。第四种8B + EVEN/ODD是非法的,但 HAL 不拦。
提示:在
MX_USART2_UART_Init里加一段编译期或运行期断言,把这个非法组合挡在启动阶段,比在通信阶段查错省事得多。
/* 启动自检:非法组合直接停在 Error_Handler,别让它带着错配置跑 */ #if defined(USE_FULL_ASSERT) assert_param(!((huart2.Init.WordLength == UART_WORDLENGTH_8B) && (huart2.Init.Parity != UART_PARITY_NONE))); #endif这段断言的成本是一条比较指令,收益是把“通信莫名丢高位”这种要查半天的现象提前到上电第一秒。
3.3 收发缓冲区的宽度,决定了数据会不会错位
这里是 HAL 库里埋得最深的一个坑,很多人栽过。HAL_UART_Transmit()的实现里有一段分支判断:
/* HAL 内部逻辑示意:只有当 9B 且无校验时才按 16 位指针递增 */ if ((huart->Init.WordLength == UART_WORDLENGTH_9B) && (huart->Init.Parity == UART_PARITY_NONE)) { pdata = (uint16_t *)pData; huart->Instance->DR = (*pdata & 0x01FF); pData += 2U; /* 指针走 2 字节 */ } else { huart->Instance->DR = (uint8_t)(*pData & 0x00FF); pData += 1U; /* 指针走 1 字节 */ }看懂这个分支就明白了两件事。第一,真正的 9 位数据模式(9B + NONE)下,传入的必须是uint16_t数组,一个元素一个 9 位字,指针按 2 字节步进。第二,8E1/8O1 虽然字长也是 9B,但因为开了校验,走的是else分支,指针按 1 字节步进,所以缓冲区必须是uint8_t数组。
如果搞反了会怎样?在 8E1 下传uint16_t数组,HAL 只读每个元素的低 8 位然后跳 1 字节,实际读到的是buf[0]低字节、buf[0]高字节、buf[1]低字节……数据整体错位,而且因为高低字节被拆开,某些字节看起来还是“合理”的,排查时极具迷惑性。
HAL_UART_Receive()有完全对称的分支判断,同理:8E1 接收用uint8_t缓冲区,9 位纯数据模式才用uint16_t。这条规则没有例外,也没有版本差异,F1 系列的 HAL 一直是这个逻辑。
4. 接收侧的硬仗:PE 标志、中断与 DMA 的三角关系
4.1 PE 置位时,硬件到底做了什么
8E1 链路上一旦出现校验错误,USART_SR的 PE 位会被置 1。这里有个容易被忽略的硬件行为:PE 置位的同时 RXNE 也会置位,因为出错的这个字节已经被完整接收并写进了DR,硬件不会因为校验错就把它扔掉。所以接收端如果只看 RXNE,就会把校验失败的字节当成正常数据读走。
清除 PE 的方式是特定序列:先读USART_SR,再读USART_DR。单独读 SR 清不掉,单独读 DR 也清不掉。HAL 里的__HAL_UART_CLEAR_PEFLAG()宏封装的正是这个序列,标准库下则是USART_ClearFlag(USART2, USART_FLAG_PE),它内部的实现是按位写SR的清除寄存器,行为等价。
顺带说 PE 的三个邻居:FE(帧错误,停止位没采到)、NE(噪声错误,采样点电平抖动)、ORE(溢出,上一字节没读走又来了新字节)。这四个错误经常连锁出现,PE 之后紧跟 ORE 是最常见的组合——因为 PE 处理逻辑没读 DR,RXNE 一直挂着,下一个字节进来就溢出。
4.2 中断接收下的清标志顺序
中断方式是最常用的接收方案,关键在错误回调里做什么。下面的回调是 HAL 版本,标准库下对应USART2_IRQHandler里判断USART_IT_PE的分支。
uint8_t rx_byte; volatile uint32_t pe_err_cnt = 0; volatile uint32_t ore_err_cnt = 0; /* HAL 错误回调:PE/ORE 处理完必须重新挂接收,否则链路停摆 */ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance != USART2) { return; } if (__HAL_UART_GET_FLAG(huart, UART_FLAG_PE) != RESET) { pe_err_cnt++; /* 只统计,不丢弃整帧策略由上层决定 */ __HAL_UART_CLEAR_PEFLAG(huart); /* 内部是读 SR 再读 DR */ } if (__HAL_UART_GET_FLAG(huart, UART_FLAG_ORE) != RESET) { ore_err_cnt++; __HAL_UART_CLEAR_OREFLAG(huart); } /* 关键:HAL 在错误路径里会把 RxState 置回 READY, 不重新调用一次 Receive_IT 就再也收不到数据 */ HAL_UART_Receive_IT(huart, &rx_byte, 1); }逻辑说明:HAL 的HAL_UART_IRQHandler在识别到 PE/ORE 后会调用UART_EndRxTransfer把接收状态机拉回就绪,然后才进错误回调。这意味着如果不在这里重新挂一次HAL_UART_Receive_IT,后续所有字节都进不了业务层,现象是“第一帧正常,之后彻底没数据”。这是 HAL 与标准库在中断处理上最大的行为差异,很多从标准库迁过来的人都会在这里卡一次。
参数说明:pe_err_cnt和ore_err_cnt用volatile修饰,因为它们在中断里改、在主循环里读。统计值本身有实用价值——如果 PE 计数在稳定通信中持续增长,说明波特率误差或线缆质量有问题,而不是对端校验算法写错了。
标准库版本的处理逻辑类似,但顺序上有个硬性要求:先读 SR 判断标志,再读 DR 清标志,然后才执行业务逻辑。
/* 标准库:中断服务函数里处理 PE,注意清标志的顺序 */ void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_PE) != RESET) { pe_err_cnt++; /* 读 SR 再读 DR,这两步由 ClearFlag 内部完成 */ USART_ClearFlag(USART2, USART_FLAG_PE); (void)USART_ReceiveData(USART2); /* 把出错字节消耗掉,避免连锁 ORE */ return; } if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t ch = (uint8_t)(USART_ReceiveData(USART2) & 0x00FF); RingBuffer_Push(&rx_fifo, ch); } }这里的(void)USART_ReceiveData(USART2)是刻意写的,目的是把出错的字节从DR里取走,让 RXNE 归零。如果不取,下一个字节到达时直接触发 ORE,错误会像滚雪球一样扩散。
4.3 DMA 接收要不要管校验位
DMA 接收在 8E1 下是完全可用的,但有两个认知必须先摆正。
第一,DMA 从USART_DR往内存搬数据时,外设数据宽度设成DMA_PDATAALIGN_BYTE,搬的是DR的低 8 位,也就是已经被硬件剥掉校验位之后的有效数据。校验位不进内存,这不是丢失,因为硬件在接收时已经校验过了。想要把 bit8 也存下来做调试,才需要把外设宽度改成 HALFWORD,缓冲区相应改成uint16_t。
第二,DMA 不会自动处理 PE 标志。校验错误时DR里仍然是数据,DMA 照搬不误,你没法从缓冲区里区分“校验通过的数据”和“校验失败的数据”。要做到这一点,只能在应用层加软件校验(比如每帧末尾的 CRC 或求和校验),或者周期性轮询 PE 计数。
/* USART2_RX 对应 DMA1 通道 6,循环模式接收定长帧 */ void USART2_DMA_Rx_Init(uint8_t *buf, uint16_t len) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_usart2_rx.Instance = DMA1_Channel6; hdma_usart2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart2_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart2_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart2_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; /* 只取低 8 位数据 */ hdma_usart2_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart2_rx.Init.Mode = DMA_CIRCULAR; /* 连续流场景用循环 */ hdma_usart2_rx.Init.Priority = DMA_PRIORITY_HIGH; if (HAL_DMA_Init(&hdma_usart2_rx) != HAL_OK) { Error_Handler(); } __HAL_LINKDMA(&huart2, hdmarx, hdma_usart2_rx); /* 启动接收前再清一次错误标志,避免上电残留导致首包丢失 */ __HAL_UART_CLEAR_PEFLAG(&huart2); HAL_UART_Receive_DMA(&huart2, buf, len); }逻辑说明:DMA_CIRCULAR适合持续数据流,缓冲区满了自动回卷,主循环靠__HAL_DMA_GET_COUNTER()算写指针位置;一帧一请求的协议应该用DMA_NORMAL,在HAL_UART_RxCpltCallback里重启。DMA_PRIORITY_HIGH在 115200 下其实用不到,但多路 DMA 并存时能减少仲裁延迟。
参数说明:PeriphDataAlignment必须与USART_DR的有效宽度匹配。设成 BYTE 只读低 8 位,设成 HALFWORD 会读低 16 位,其中 bit8 是校验位、bit9~bit15 是保留位(读出来是 0)。如果设成 BYTE 而业务侧按 16 位解析,同样会错位。
还有一点容易被忽略:F1 的 DMA 通道与 USART 的对应关系是固定的,USART2_RX 只能走 DMA1 通道 6,USART2_TX 只能走通道 7。通道选错不会报错,只会静默不工作。
5. 避坑与排查:奇偶校验五个高频翻车点
5.1 收到的数据最高位永远是 0
现象:对端发 0x80~0xFF 之间的数据,本地收到的全是 0x00~0x7F,低 7 位完全正确,最高位恒为 0。换成奇校验,最高位恒为 1。
原因:只开了 PCE,M 位还是 0。硬件把第 8 个数据位的位置让给了校验位,有效数据只剩 7 位。
解决:把USART_WordLength改成 9b,HAL 下是UART_WORDLENGTH_9B。改完用 0x5A、0xAA、0xFF 三个值各发一遍,逻辑分析仪上确认帧长是 11 位而不是 10 位。
5.2 CubeMX 生成代码不报错,通信全错
现象:编译零警告,HAL_UART_Init返回HAL_OK,示波器上有波形,但对端全收错。
原因:CubeMX 里 WordLength 选了 8B、Parity 选了 Even,生成的MX_USART2_UART_Init把CR1写成M=0, PCE=1。HAL 的UART_SetConfig只做按位或,不做组合合法性检查。
解决:在HAL_UART_Init之前加断言或运行期检查,把8B + 非 NONE这种组合挡掉。更稳妥的做法是在HAL_UART_MspInit之后读回USART2->CR1的M位和PCE位,确认两者同时为 1。
5.3 第一帧正常,之后彻底收不到数据
现象:上电后能收到头几个字节,线上出现一次校验错误之后,接收中断再也不进。
原因:HAL 的错误路径把RxState置回 READY 并终止了接收传输,HAL_UART_ErrorCallback里没有重新调HAL_UART_Receive_IT。
解决:在错误回调末尾按当前使用的接收方式重新挂载——中断方式调HAL_UART_Receive_IT,DMA 方式调HAL_UART_Receive_DMA。同时把 PE 计数打出来,确认是偶发还是持续性错误。
5.4 8E1 下传了 uint16_t 数组,数据整体错位
现象:发送缓冲区内容肉眼可见地“被拆开”,第 1、2 个收到的字节分别是原数组第 1 个元素的低字节和高字节。
原因:HAL 的发送函数只在9B + NONE组合下按 16 位指针步进,8E1 走的是 8 位分支。传uint16_t数组相当于把每个元素当两个字节发。
解决:8E1/8O1 一律用uint8_t缓冲区。真需要 9 位数据(无校验)时才用uint16_t,且此时Parity必须是UART_PARITY_NONE。
5.5 偶发 PE,重启就好,跑久了必现
现象:刚上电通信正常,跑十几分钟后开始零星报 PE,示波器上看波形没有明显畸变。
原因:波特率量化误差叠加晶振偏差。APB1 若是 36MHz,115200 的 BRR 误差约 -0.16%,再叠加外部晶振 ±20ppm 和片内 RC 偏差,累计误差可能逼近 8E1 的容错边界。另外长线缆、未加终端匹配也会让采样点抖动。
解决:先把 APB1 配到 36MHz 满速,确认 BRR 计算用的是实际频率;其次缩短线缆或降低波特率到 57600 验证;最后检查 RX 引脚是否配置了上拉,浮空输入在长线上很容易引入噪声。这三个动作做完,PE 计数应该归零。
6. 把链路钉死:回环自测、误码统计与逻辑分析仪
配置改完不算结束,得有一套能自证的方法。我现在固定的三步是:回环、统计、抓帧。
第一步是硬件回环。把 PA2 和 PA3 用杜邦线短接,配置成 8E1 后自发自收 256 个字节(0x00~0xFF 全遍历),比对收发缓冲区。这一步能验证CR1的 M/PCE/PS 三位的联动是否真的生效——因为回环的发送端和接收端是同一套寄存器配置,一旦 M 位配错,自发自收会立刻暴露数据截断。
/* 回环自测:PA2 短接 PA3,跑 0x00~0xFF 全遍历 */ uint8_t tx[256], rx[256]; uint32_t err = 0; for (uint16_t i = 0; i < 256; i++) { tx[i] = (uint8_t)i; } HAL_UART_Transmit(&huart2, tx, 256, 1000); HAL_UART_Receive(&huart2, rx, 256, 1000); for (uint16_t i = 0; i < 256; i++) { if (rx[i] != tx[i]) { err++; } } /* err 必须为 0;非 0 时优先查 M 位和缓冲区宽度 */第二步是误码统计。在HAL_UART_ErrorCallback里累加pe_err_cnt,主循环每秒打印一次,同时打印接收总字节数,算出误码率。稳定链路的 PE 计数应该是 0;如果每秒都在涨,说明物理层有问题,改软件没用。
第三步是逻辑分析仪。按 11 位、LSB 先、校验位 Even/Odd 解码,抓一帧 0x5A 手算校验位对一遍。这一步能区分“发送端算错校验位”和“接收端判错校验位”,前者换芯片或换库,后者查采样点和波特率。表格里是我常用的三个检查点:
| 检查项 | 正常表现 | 异常时的指向 |
|---|---|---|
| 帧长 | 11 位 | 10 位说明 M=0,PCE 没生效或没联动 |
| 校验位极性 | 偶校验时 1 的个数为偶 | 极性反了说明 PS 位配反 |
| 位宽一致性 | 每位宽度偏差 < 2% | 超过说明波特率误差大或时钟源不对 |
最后说个习惯。从那以后我每次配带校验的串口,都强制走一遍“先看 CR1 的 M/PCE 是否成对,再确认缓冲区元素宽度”这两步,然后才上电。这个顺序看着笨,但它把最容易翻车的两个点前置到了写代码阶段,比在示波器前面耗一晚上划算得多。这套 STM32F103 串口2 带奇偶校验的配置和排查路径,直接拿去改外设编号就能用,希望帮到你。
本文还有配套的精品资源,点击获取