ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103 USART2串口偶校验配置与9位帧收发避坑

STM32F103 USART2串口偶校验配置与9位帧收发避坑

简介:这份STM32F103串口2带奇偶校验工程面向正在学习STM32 USART通信的嵌入式开发者与高校学生,聚焦在STM32F103C8T6上完成USART2的奇偶校验配置与收发验证,适合已掌握GPIO、时钟基础、希望补齐串口参数设置与工程组织能力的中初级读者。压缩包共78个文件,约308KB,以33个C源文件和34个头文件为主体,辅以Keil工程配置、HEX目标文件、启动汇编、调试配置及说明文本,涵盖标准外设库、UART2驱动、LED、按键、延时与系统模块,便于直接编译、烧录和对照分析。已有2446人学习下载。工程把时钟使能、PA2/PA3复用、波特率与停止位、PCE/PS奇偶校验位设置等关键点落在可运行代码中,读者可据此理解USART_CR1寄存器配置,并参考中断或DMA扩展思路完成非阻塞收发调试。

1. 调通一台带偶校验的老仪表,卡住的往往不是波特率

去年帮人接一台老式称重仪表,协议纸上印着「9600,8 位数据,偶校验,1 位停止」。我照着习惯把 STM32F103 的串口2 配成 8N1,发送正常,仪表也确实回了数据,可 STM32 这边收到的全是 0x00,偶尔进一次中断,读出来的字节高位还莫名其妙。折腾半天才发现,问题不在波特率,也不在接线,而在 USART_CR1 里那三个位:PCE、PS、M。STM32F103 单片机串口2 带奇偶校验这件事,难点从来不是「打开校验开关」,而是打开之后帧长怎么算、数据位还剩几位、接收端怎么把校验位从数据里剥出来。

这份工程做的就是这件事:把 USART2 配成带奇校验或偶校验的可用状态,覆盖 GPIO 复用、波特率、9 位帧收发、PE 错误标志清除和中断处理,编译后直接跑在 F103 的 PA2/PA3 上。适合正在对接仪表、PLC、老设备的开发者,也适合已经写过串口但被校验位坑过一次的人。

2. 串口2 的寄存器地基:APB1 时钟、PA2/PA3 与 CR1 里那三个位

2.1 USART2 挂在 APB1 上,时钟和引脚都别想当然

STM32F103 的三个 USART 不是同一条总线上的住户。USART1 挂 APB2,最高 72MHz;USART2 和 USART3 挂 APB1,最高 36MHz。这个差别直接影响波特率寄存器 BRR 的计算,很多人从 USART1 的代码抄到 USART2,波特率就偏了,串口助手上一堆乱码,还以为是晶振问题。我一般会在初始化开头就把 RCC 的使能写死,避免后面加了别的外设又把 APB1 分频改掉。

/* 串口2 在 APB1,GPIOA 在 APB2,两条总线都要开 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); /* 若工程里改过时钟树,这里要跟 SystemInit 保持一致 * 默认 72MHz 主频下:APB1 = 36MHz,APB2 = 72MHz * USART2 的波特率计算基数必须用 36MHz,不是 72MHz */

引脚方面,USART2 的默认映射是 PA2 做 TX、PA3 做 RX。F103 的 PA2/PA3 还兼着 TIM2_CH3/CH4 和 ADC 通道,如果工程里同时开了 Timer2 的 PWM,就得留意有没有冲突。需要挪到 PD5/PD6 的话,得先开 AFIO 时钟再调GPIO_PinRemapConfig(GPIO_Remap_USART2, ENABLE),这一步漏了,引脚上永远没有波形。

TX 配成复用推挽输出(GPIO_Mode_AF_PP),速度 50MHz 足够;RX 配成浮空输入或上拉输入(GPIO_Mode_IN_FLOATING/GPIO_Mode_IPU)。RX 用上拉更稳一点,尤其是外部设备掉电时线悬空,浮空输入容易收到噪声,表现就是「没接设备也一直进接收中断」。

2.2 PCE、PS、M 三个位,决定了你到底能传几个数据位

这是整件事的核心。USART_CR1 里跟校验直接相关的有三个位:

位名称作用
bit10PCE校验使能,1 打开
bit9PS校验选择,0 偶校验,1 奇校验
bit12M字长,0 为 8 位,1 为 9 位

关键在于 PCE 和 M 的联动。参考手册写得很清楚:使能校验位后,校验位占用数据帧的最高位。也就是说,M=0(8 位字长)且 PCE=1 时,这一帧是「7 位数据 + 1 位校验」;只有 M=1(9 位字长)且 PCE=1,才是「8 位数据 + 1 位校验」。

这就是很多人翻车的地方:协议要求 8 位数据 + 偶校验,代码里只打开了 PCE,M 还是 0,结果发出去的字节最高位永远被当成校验位吃掉,0x80 以上的数据全错,0x00~0x7F 勉强能通,看上去「大部分正常」,特别迷惑。而如果接收端按 8 数据位解析,收到的每个字节都是被截断的 7 位值,表现出来就是「数值总是比实际小一半」或者「高位丢失」。

所以规则先记死:要 8 位数据 + 校验,必须 M=1;要 7 位数据 + 校验(部分老仪表就是这种),M=0 才对。收发两端的帧格式必须严格对齐,不能一边 8 数据位 + 校验、另一边 9 位无校验,那样校验位会被对方当数据收进去。

2.3 BRR 的计算跟校验无关,但帧长会影响吞吐和时序判断

波特率寄存器 BRR 只跟 APB 时钟和波特率有关,开不开校验不影响它,这一点可以放心。标准库里的USART_InitStructure.USART_BaudRate会自动帮你算,HAL 里也是。真正受影响的是每帧的位数:8 数据位 + 1 校验 + 1 起始 + 1 停止 = 11 位,比常见的 8N1 多一位,同样的波特率下有效吞吐略低。

这个差异在普通收发里感觉不到,但在两种场景会咬人:一是用定时器判帧间隔做协议解析的,帧长变了,超时阈值要跟着调;二是用 DMA 按固定字节数搬运的,如果上位机每帧长度没算校验位,DMA 的 buffer 就会错位。我一般会在协议解析层按「字节流」处理,不依赖单帧长度,把校验交给硬件,软件只管数据内容。

还有一个容易忽略的点:STM32F1 的 USART 在接收时,校验位会被存进 DR 的第 8 位(M=1 时)。也就是说你从USART_ReceiveData()拿到的值可能带着 bit8,直接当 uint8_t 用会被截断,看起来没问题,但如果代码里做了if (data == 0x55)这种比较,而 DR 里是 0x155,比较就会失败。稳妥做法是读出来就& 0xFF。

3. 初始化代码拆解:一份能直接编译的 USART2 偶校验配置

3.1 从 GPIO 到 USART,顺序错了会静默失败

标准外设库(SPL)写 USART2 的初始化,顺序建议固定成:开时钟 → 配 GPIO → 配 USART → 配 NVIC → 使能 USART。先使能 USART 再配 GPIO,很多时候也能跑,但引脚上会先冒出一段无意义的电平,外接 485 芯片时可能误触发。下面这段是能直接编译进工程的完整初始化,参数按「9600、8 数据位、偶校验、1 停止位」配。

#include "stm32f10x.h" /* 初始化 USART2:9600, 8 数据位, 偶校验, 1 停止位 * 注意 M=1 才能凑出 8 数据位 + 1 校验位 */ void USART2_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; /* 1. 时钟:GPIOA 在 APB2,USART2 在 APB1 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); /* 2. PA2 = TX,复用推挽;PA3 = RX,上拉输入 */ 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); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 3. USART2 参数 * WordLength = 9b:配合 PCE 形成 8 数据位 + 1 校验位 * Parity = Even:偶校验,PS 位写 0 */ USART_InitStructure.USART_BaudRate = 9600; USART_InitStructure.USART_WordLength = USART_WordLength_9b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_Even; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &USART_InitStructure); /* 4. 接收中断:数据到达和校验错误共用同一个中断向量 */ NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); /* 接收非空 */ USART_ITConfig(USART2, USART_IT_ERR, ENABLE); /* PE/ORE/FE/NE 错误中断 */ USART_Cmd(USART2, ENABLE); }

参数说明上,USART_WordLength直接对应 CR1 的 M 位,写USART_WordLength_9b就是把 M 置 1,这是整份代码里最容易抄错的一行。USART_Parity_Even对应 PCE=1、PS=0,库函数会自动把这两位一起写进去,不需要再手动操作寄存器。USART_Mode里如果只用接收,可以去掉 Tx,但 GPIOD 的 TX 脚还是建议配好,调试时能挂逻辑分析仪看波形。

USART_ITConfig(USART2, USART_IT_ERR, ENABLE)这一句对应 CR3 的 EIE 位。不开它,校验错误照样会置位 SR 里的 PE,但不会进中断,你只能在收完数据之后去查标志,容易漏。开了之后,PE、ORE、FE、NE 都会触发同一个USART2_IRQHandler,中断里必须逐个判断。

3.2 时钟树变了,BRR 就是玄学

USART_Init内部会读RCC_GetClocksFreq()拿 APB1 的实际频率来算 BRR。如果工程里改过 PLL 或者 APB1 分频,而SystemInit没同步改,算出来的 BRR 就是错的。表现是:串口助手上收到的全是乱码,换个波特率又能对上一点。

我一般在上板第一件事就是量波形,用逻辑分析仪抓一个 0x55,8 数据位 + 校验应该是 11 个位宽,量一位的时间反推波特率,看跟 9600 差多少。误差超过 3% 就要回头查时钟。另一个土办法是在初始化后把USART2->BRR的值打印出来,按公式BRR = 36MHz / 波特率手算一遍对一下,对不上就是时钟基数错了。

APB1 分频不为 1 时,F103 的定时器和 USART 时钟会有一个自动倍频规则,标准库的RCC_GetClocksFreq()已经处理了,所以只要用库函数算,一般不踩这个坑;自己手写 BRR 的才要小心。

3.3 中断优先级:别让串口把 systick 挤掉

USART2_IRQn 的抢占优先级给了 2,比 SysTick 低。串口中断里不要做耗时操作,尤其是带校验的场景,上位机可能连续发几百字节,中断频率不低。收到字节就塞环形缓冲区,解析放到主循环里做,这是常规做法。

如果工程里同时开了 DMA 和串口中断,要注意 DMA 传输完成中断和 USART 的 PE 中断可能同时发生,处理 PE 的时候读 DR 会把 DMA 正在搬的数据抢走。这种组合我一般二选一:要么纯中断收,要么纯 DMA + 空闲中断收,混着用容易出黑匣子式的问题——现象诡异,日志又看不出来。

3.4 HAL 库里的对应写法,以及它没帮你做的事

现在新工程大多用 HAL,配置项名字变了,但底层还是那三个位。CubeMX 里把 Word Length 选成 9 Bits、Parity 选 Even,生成的代码就是对的;如果选了 8 Bits + Even,那就是 7 数据位 + 校验,跟协议对不上。

/* HAL 版本:huart2.Init 里字段与 SPL 一一对应 */ huart2.Instance = USART2; huart2.Init.BaudRate = 9600; huart2.Init.WordLength = UART_WORDLENGTH_9B; /* M=1 */ huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_EVEN; /* PCE=1, PS=0 */ huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart2.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart2) != HAL_OK) { Error_Handler(); } /* HAL 不会因为校验错误自动丢帧,PE 依旧要自己处理 */ __HAL_UART_ENABLE_IT(&huart2, UART_IT_ERR);

HAL 的HAL_UART_Receive_IT在收到校验错误的帧时,行为跟版本有关:有的版本会直接返回 HAL_ERROR 并把 huart->ErrorCode 置成HAL_UART_ERROR_PE,有的版本继续把数据交给你。所以用 HAL 时别假设它帮你过滤了错误帧,接收回调里还是要看 ErrorCode。这一点我在两个不同版本的 HAL 上都验证过,行为确实不一致,升级库的时候要重新测。

4. 收发链路:9 位帧、PE 标志与中断里的校验错误处理

4.1 发送侧:硬件自动补校验位,你只管写低 8 位

发送很简单,M=1 时 DR 是 9 位宽,但硬件只会用你写入的低 8 位作为数据,第 9 位由硬件根据 PCE/PS 自动生成,不需要你算。所以发送函数跟普通串口没区别,唯一要注意的是别把校验位手动塞进 bit8,塞了反而会干扰。

/* 发送一个字节:低 8 位是数据,校验位由硬件生成 */ void USART2_SendByte(uint8_t data) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET) { /* 等待发送数据寄存器空,注意不是 TC */ } USART_SendData(USART2, (uint16_t)data); } /* 发送一串数据,最后一个字节后等 TC,保证真正发完 */ void USART2_SendBuf(const uint8_t *buf, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { USART2_SendByte(buf[i]); } while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET) { /* 等最后一个字节的停止位移出,485 切方向必须等这个 */ } }

TXE表示 DR 空了可以写下一个,TC表示整个帧(含停止位)已经从移位寄存器发完。普通 TTL 串口不等 TC 也没事,但接 RS485 时,早切方向会把最后一个字节的停止位截掉,对方收到帧错误。血泪经验:485 方向切换要放在 TC 之后,而不是 TXE 之后。

4.2 接收中断:先读 SR 再读 DR,顺序是硬要求

F1 的 USART 清除 PE、ORE、FE、NE 这些标志的方式是「先读 SR,再读 DR」。顺序反了,标志清不掉,中断会反复进,主循环直接饿死。

/* 环形缓冲区,中断里只做搬运 */ #define RX_BUF_SIZE 256 static volatile uint8_t rx_buf[RX_BUF_SIZE]; static volatile uint16_t rx_head = 0, rx_tail = 0; void USART2_IRQHandler(void) { /* 关键:先读 SR,把当前所有标志一次性取出来 */ uint16_t sr = USART2->SR; /* 校验错误:PE 置位说明当前帧校验不通过 */ if (sr & USART_FLAG_PE) { /* 读 DR 完成清标志,读到的数据丢弃 */ (void)USART2->DR; g_parity_err_cnt++; /* 统计用,方便现场判断线缆质量 */ return; } /* 溢出错误:上一帧还没读走,新数据又来了,必须处理否则会锁死 */ if (sr & USART_FLAG_ORE) { (void)USART2->DR; g_overrun_cnt++; return; } /* 正常收到数据 */ if (sr & USART_FLAG_RXNE) { uint8_t data = (uint8_t)(USART2->DR & 0xFF); /* 剥掉 bit8 校验位 */ uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { rx_buf[rx_head] = data; rx_head = next; } else { g_drop_cnt++; /* 缓冲区满,丢新数据,不覆盖旧数据 */ } } }

这段里有三个细节。第一,USART2->DR & 0xFF是必须的,M=1 时 DR 的 bit8 是校验位,不屏蔽的话数值比较会出问题,比如收到 0x55 变成 0x155,if (data == 0x55)永远不成立。第二,PE 分支里也要读一次 DR,否则 PE 标志清不掉,中断会一直重入。第三,ORE 必须处理,F1 上 ORE 置位后如果不读 DR,接收会被硬件挂起,表现就是「串口突然死了,复位才好」,这是现场最常见的故障之一。

4.3 校验错误怎么处置:丢弃、重传还是上报

硬件只负责告诉你「这一帧校验不对」,怎么处理是软件的事。我一般分三种场景:

  • 传感器周期上报数据:直接丢,等下一帧。偶发一两个错误帧说明线缆有干扰,统计计数超过阈值再报警。
  • 命令应答类协议:丢弃并触发重发,重发次数设 3 次,超过就上报超时。
  • 长包传输:校验错误意味着整包作废,必须从包头重新同步,不能只丢一个字节,否则后面的数据会错位。

有个容易忽略的点:PE 是每帧独立的,硬件不会因为上一帧错了就把后面的帧也标记错。所以统计错误率时,用「错误帧数 / 总帧数」比用绝对次数有意义,现场布线上千帧里错两三帧是正常的,连续错就要查终端电阻和地线。

如果协议本身在应用层还有 CRC,硬件奇偶校验其实是第二道防线,主要作用是让接收方尽早丢弃坏帧,减少上层解析压力。不要指望它替代 CRC,奇偶校验只能发现奇数个位错误,偶数个位翻转是查不出来的。

5. 避坑与排查:奇偶校验场景下最容易翻车的 5 个点

5.1 现象:0x00~0x7F 正常,0x80 以上全错

原因:只开了 PCE 没改 M,帧长还是 8 位,校验位占了最高位,所有高位为 1 的数据都被吃掉。这是最高频的一个坑,因为低地址数据看着完全正常,很容易误判成「协议解析错了」。

解决:把USART_WordLength改成USART_WordLength_9b(HAL 里是UART_WORDLENGTH_9B),确认 CR1 的 M 位为 1。改完用 0xAA、0x55、0xFF 三个值做回环测试,这三个值的位模式能覆盖大部分位置。

5.2 现象:接收中断疯狂进,主循环卡死

原因:PE 或 ORE 标志没清掉。F1 的清标志序列是「读 SR → 读 DR」,只读 SR 或者只读 DR 都不行,中断会一直挂在那儿。

解决:中断入口先uint16_t sr = USART2->SR;,然后在每个错误分支里都读一次USART2->DR。用标准库的USART_GetITStatus也可以,但要注意它内部也读 SR,混用容易把顺序搞乱,建议直接操作寄存器更直观。

5.3 现象:接上设备就收到乱码,拔掉设备反而正常

原因:RX 配成了浮空输入,设备未上电或线缆悬空时引脚电平不定,噪声被当成起始位收进来。带校验时这些噪声帧大多会被 PE 拦掉,但偶尔能凑出合法校验,就变成乱码进缓冲区。

解决:RX 改上拉输入,或者在外部加 10k 上拉。另外可以在协议层加帧头判断,只有帧头对上的数据才进解析。现场还遇到过长线缆没加终端电阻的情况,1200 米线跑 9600,错误率能到 5%,加了终端电阻降到千分之一以下。

5.4 现象:跟 PC 串口助手能通,跟目标设备不通

原因:两边帧格式没对齐。串口助手默认 8N1,你这边是 8 数据位 + 偶校验,助手那边会把校验位当第 9 位丢掉或者显示成乱码,看上去「能收到字符」,实际每一位的含义都不一样。

解决:串口助手也要设成 8 数据位 + 偶校验 + 1 停止位(注意助手里「数据位」通常不含校验位,选 8 就对了)。如果助手不支持校验位配置,就换一个支持的工具,或者干脆用两块板子对发验证。对发现场设备时,先用示波器量一位宽度确认波特率,再抓一帧看校验位电平,比盲猜快得多。

5.5 现象:DMA 接收时数据整体偏移一位

原因:开了校验位之后帧长变成 9 位,DMA 按字节搬的时候会把校验位也当成数据的一部分,或者外设配置的数据宽度跟帧长不匹配。这类问题最像黑匣子,因为 DMA 不报错,数据就是错的。

解决:USART 的 DMA 接收要配成 8 位外设数据宽度,同时确保 M=1 时 DR 的低 8 位才是有效数据。稳妥做法是 DMA 只负责搬,搬完在空闲中断里统一做一次& 0xFF和校验标志检查,不要指望 DMA 帮你处理协议层。如果项目不追求极致吞吐,带校验的场景用中断收反而更省心。

6. 二次开发:把校验配置抽成可复用的串口中间件

工程跑通之后,通常会遇到第二个、第三个串口,参数还不一样:串口1 接调试口 8N1,串口2 接仪表 8E1,串口3 接另一台设备 8O1。这时候把初始化代码复制三份,改起来就是灾难。我一般会抽一层薄中间件,把「帧格式」当成结构体参数传进去。

/* 串口帧格式描述,硬件无关 */ typedef struct { uint32_t baudrate; uint8_t data_bits; /* 7 或 8 */ uint8_t parity; /* 0=无, 1=奇, 2=偶 */ uint8_t stop_bits; /* 1 或 2 */ } uart_frame_t; /* 根据 data_bits 和 parity 推导 WordLength,避免手填出错 */ static uint16_t frame_to_wordlen(const uart_frame_t *f) { if (f->parity == 0) { return (f->data_bits == 8) ? USART_WordLength_8b : USART_WordLength_9b; } /* 带校验时,数据位 8 必须用 9 位帧长承载校验位 */ return (f->data_bits == 8) ? USART_WordLength_9b : USART_WordLength_8b; }

这个推导函数看着简单,但把最容易错的映射关系收进了一处。以后加新串口,只要填{9600, 8, 2, 1}这样的描述,帧长自动算对,不会出现「复制粘贴忘了改 M 位」的问题。改完之后我会做三组验证:回环测试(TX 短接 RX 自发自收)、对端设备实测、故意拔掉 RX 线看错误计数是否正常增长。三组都过才算改完。

再往上一层,可以把接收回调做成函数指针注册,中断里只做缓冲,主循环轮询分发。这样串口2 的仪表协议和串口3 的 PLC 协议互不干扰,新增设备只要注册一个解析函数。

typedef void (*uart_rx_cb_t)(const uint8_t *buf, uint16_t len); static uart_rx_cb_t s_uart2_cb = 0; void UART2_RegisterRxCallback(uart_rx_cb_t cb) { s_uart2_cb = cb; } /* 主循环里调用,把环形缓冲区的数据整块交给上层 */ void UART2_Poll(void) { uint8_t tmp[64]; uint16_t n = 0; while (rx_tail != rx_head && n < sizeof(tmp)) { tmp[n++] = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; } if (n > 0 && s_uart2_cb) { s_uart2_cb(tmp, n); } }

这里有个取舍:中断里只搬字节、不做协议解析,代价是多一层缓冲和一次内存拷贝,好处是解析耗时再长也不会丢中断。带校验的场景尤其需要这样,因为 PE 错误帧的处理本身就要读 DR,如果中断里还混着协议解析,很容易把 ORE 拖出来。

从那以后我每次配带校验的串口,都强制先跑一遍「0x55 单字节 + 回环 + 逻辑分析仪量位宽」这三步,再往下写业务代码。这三步花不了十分钟,能省掉后面几小时的猜谜。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进