简介:面向嵌入式初学者与单片机开发者的STM32F103串口通信实践工程,聚焦C8T6上USART2带奇偶校验的配置与收发。工程围绕APB1时钟使能、PA2/PA3引脚复用、波特率与数据位设置,以及USART_CR1中PCE、PS位的奇偶校验选择展开,可用于传感器、显示模块或调试串口的对接练习。压缩包共78个文件、约308KB,以34个h头文件和33个c源文件为主体,另含Keil工程、启动汇编、hex烧录文件与批处理脚本,目录按HARDWARE、SYSTEM、CORE、STM32F10x_FWLib、USER、OBJ划分,驱动与库文件层次清楚。已有2446人学习,读者可据此对照标准外设库的串口初始化流程,查看UART2驱动、LED与按键模块的配合,理解校验位对收发的影响,并在此基础上改造成中断或DMA方式,积累寄存器级调试与工程编译经验。
1. 那一 bit 校验位,改的不是协议,是 USART 的数据宽度
在 STM32F103 上给串口2加奇偶校验,很多人的第一反应是打开USART_CR1里的 PCE 位就完事。烧进去才发现数据错了:发出去0xA5,对端收到0x25;或者接收端读回来的值永远比预期多出0x100。这不是玄学,而是这颗单片机 USART 的一条硬规则——校验位不是“额外附加”在数据后面的,它抢占的正是数据帧的最高位。所以串口2带奇偶校验的本质问题是:你到底还剩几个数据位可用。把这 1 bit 的来龙去脉搞清楚,PCE、M、PS 三个位的关系、PE 标志的清除时序、9 位数据怎么塞进 16 位的 DR,就全部能串起来。
2. 帧结构:M 位和 PCE 位为什么会互相吃掉一位数据
2.1 从起始位到停止位:一帧到底有几个 bit
异步串口没有时钟线,收发双方靠“起始位下降沿”对齐,然后按约定的波特率逐位采样。一帧的固定骨架是:空闲时线路保持高电平,起始位拉低 1 个位时间,接着是数据位(低位先发),然后是可选校验位,最后是停止位拉高。8 位数据、无校验、1 位停止位,就是常说的 8N1,一共 10 个位时间;加上偶校验就是 8E1,一共 11 个位时间。
这里有个容易被忽略的细节:波特率误差的容忍度和帧长成反比。10 位的帧,累积到停止位时的相位偏移只要不超过半个位时间就能采对,余量大约 ±5%;11 位的帧余量缩到 ±4.5% 左右。所以在 36MHz 的 APB1 上跑 115200 带校验,误差必须认真算,不能想当然地认为“以前 8N1 能通,加个校验位也照样通”。
2.2 M=0 配 PCE=1 的真实帧:你只剩 7 个数据位
参考手册里那句关键描述是:校验位占用数据帧的最高位。也就是说,PCE 一旦置 1,硬件会把移位寄存器里本该发出去的最高位数据“顶掉”,换成算出来的校验位。于是 M 位(字长)和 PCE 位就产生了耦合:M=0 声明的是“8 位帧”,这 8 位里被校验位占掉 1 位,真正能承载数据的只剩 7 位。
| M(字长) | PCE | PS | 帧内数据位 | DR 有效位 | 典型形态 |
|---|---|---|---|---|---|
| 0 | 0 | — | 8 | DR[7:0] | 8N1,调试口最常见 |
| 0 | 1 | 0/1 | 7 | DR[6:0] | 很少有人故意这么配 |
| 1 | 0 | — | 9 | DR[8:0] | 9 位多机协议、地址位 |
| 1 | 1 | 0/1 | 8 | DR[7:0] | 8E1 / 8O1,带校验的正确形态 |
表格最后一行才是“8 位数据 + 偶校验”的正确配置:字长必须设成 9 位,把校验位算进帧长度里,这样 8 个数据位才完整。标准库写作USART_WordLength_9b+USART_Parity_Even,HAL 写作UART_WORDLENGTH_9B+UART_PARITY_EVEN。第一次踩这个坑的人,八成都是配了USART_WordLength_8b+USART_Parity_Even,然后对着数据抓狂。
2.3 一次可复现的对照实验:发 0xA5,收到 0x25
把上面那个错误配置烧进去,发一个0xA5,能非常直观地看到后果。0xA5的二进制是1010 0101。因为 M=0、PCE=1,硬件只把低 7 位010 0101当数据,最高位1被丢弃。偶校验要求“数据位 + 校验位里 1 的个数为偶数”,低 7 位里有 4 个 1,已经是偶数,所以校验位补0。对端如果按 8N1 收,看到的 8 个 bit 就是0 010 0101,也就是0x25。
这个实验最扎心的地方在于:如果对端同样配了 8E1,它收到0x25,自己算一遍偶校验,0x25里 4 个 1,校验通过,不报错。数据错了,链路却显示一切正常——这才是“黑匣子”级别的问题。排查时不要盯着波特率和接线,先把 M 位和 PCE 位一起看。
2.4 改成 9 位字长之后,DR 里多出来的那一位是什么
配置改对之后,USART_DR变成一个 9 位的寄存器。发送时你写进 DR 的低 8 位是数据,第 9 位会被硬件替换成校验位,写什么都会被覆盖;接收时读出来的 9 个 bit 里,最高位是链路上传过来的校验位本身。所以标准库的USART_ReceiveData()返回uint16_t,但你必须再& 0xFF才是有效数据,否则缓冲区里会混进0x100这样的“幽灵值”,后面做协议解析时表现为“偶尔多出一个高位”。
提示:判断自己有没有配错,最省事的办法是在初始化后回读
USART2->CR1,看 bit12(M)和 bit10(PCE)是不是同时为 1。
3. 发送路径:硬件怎么在移位寄存器里“顺手”生成校验位
3.1 TDR、移位寄存器与 TXE/TC:一个字节的一生
写USART_DR并不等于立刻上线。这个写操作把数据装进发送数据寄存器 TDR,TDR 空且移位寄存器也空时,硬件把数据搬进移位寄存器,然后自动补上起始位、逐位输出数据、插入校验位、最后补停止位。整个过程 CPU 完全不参与,这也是为什么校验位的生成“不要钱”。
TXE 标志表示 TDR 已空,可以写下一个字节;TC 标志表示移位寄存器也空了,最后一个停止位已经完整送出线路。做半双工方向切换(比如 485 收发器使能脚)时,必须等 TC 而不是 TXE,否则最后一个字节的停止位还没发完就切了方向,对端会收到一个被截断的帧——现象是“大部分包正常,偶尔最后一字节 CRC 错”,特别难查。
校验位本身怎么算?偶校验就是所有数据位做异或:异或结果为 0 表示 1 的个数是偶数,校验位补 0;为 1 则补 1 凑成偶数。奇校验取反。8 个数据位只需要 7 级异或,硬件用一个极小的组合逻辑就能完成,延迟可以忽略。
/* 软件侧算一遍偶校验,用来核对硬件的行为是否符合预期 */ static uint8_t even_parity8(uint8_t v) { v ^= v >> 4; /* 高 4 位折叠到低 4 位 */ v ^= v >> 2; /* 再折叠 */ v ^= v >> 1; /* 最后 1 位就是全部 8 位的异或 */ return (uint8_t)(v & 0x01); /* 返回 1 表示数据位里 1 的个数为奇数,校验位要补 1 */ }这段代码在自测时很有用:把要发的字节和实际抓到的校验位比一遍,能立刻区分“是我配错了 M 位”还是“对端协议理解错了”。
3.2 标准库那几行配置,究竟写进了哪些寄存器位
void Uart2_Init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef uart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); /* 串口2挂在 APB1,不是 APB2 */ gpio.GPIO_Pin = GPIO_Pin_2; /* PA2 = USART2_TX */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_3; /* PA3 = USART2_RX */ gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; /* 接收脚必须浮空输入,别配成推挽 */ GPIO_Init(GPIOA, &gpio); uart.USART_BaudRate = baud; uart.USART_WordLength = USART_WordLength_9b; /* CR1 bit12 = 1,9 位帧 */ uart.USART_Parity = USART_Parity_Even; /* CR1 bit10 PCE=1,bit9 PS=0 */ uart.USART_StopBits = USART_StopBits_1; uart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; uart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART2, &uart); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); USART_ITConfig(USART2, USART_IT_PE, ENABLE); /* 单独开 PE 中断,才知哪些帧坏了 */ USART_Cmd(USART2, ENABLE); }USART_Init内部做的事比看上去多:它先把 CR1 的 M(bit12)、PCE(bit10)、PS(bit9)按参数置位,再按 CR2 配停止位,然后把波特率算成定点数写进 BRR。注意USART_Parity_Even对应 PS=0,USART_Parity_Odd对应 PS=1——偶校验是“数据位与校验位中 1 的总数为偶数”,这是最通用的约定,但也有规约规定的是“校验位等于数据位异或”,两者结果一致,只是描述角度不同。
时钟这一行值得单独强调:USART2 和 USART3 挂 APB1(最高 36MHz),USART1 挂 APB2(最高 72MHz)。同一条代码里如果照抄了 USART1 的RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ...),串口2根本不会工作,但编译链接一路绿灯。
3.3 BRR 里的定点小数:36MHz 下 115200 的真实误差
BRR 的结构是 12 位整数部分 + 4 位小数部分,本质是一个 ×16 的定点数。波特率公式为baud = fCK / (16 × USARTDIV)。理论值算出来是无限小数时就得四舍五入,而标准库为了在整数运算里保住精度,用了“先乘 25 再除 4”这种等价变形,把 USARTDIV ×100 放大成整数再拆位:
/* stm32f10x_usart.c 中的定点运算,等价于 USARTDIV × 100 */ integerdivider = ((25 * apbclock) / (4 * baud)); tmpreg = (integerdivider / 100) << 4; /* 整数部分 */ fractionaldivider = integerdivider - (100 * (tmpreg >> 4)); /* 余下的百分位 */ tmpreg |= (((fractionaldivider * 16) + 50) / 100) & 0x0F; /* +50 是四舍五入 */在 36MHz 的 APB1 下算出来的几个典型值:
| 目标波特率 | USARTDIV 理论值 | BRR 实际写入 | 实际波特率 | 误差 |
|---|---|---|---|---|
| 9600 | 234.375 | 0xEA6 | 9600 | 0 |
| 19200 | 117.1875 | 0x753 | 19200 | 0 |
| 57600 | 39.0625 | 0x271 | 57600 | 0 |
| 115200 | 19.53125 | 0x138 | 115385 | +0.16% |
115200 是唯一有误差的常用值,因为 36MHz 除以 16 再除以 115200 得不到 1/16 的整数倍。+0.16% 在 11 位的帧上累积不到 0.02 个位时间,完全无害,但如果有人为了“跑快点”把 APB1 分频改成 24MHz 却忘了重算 BRR,误差会直接飙到几个百分点,表现是“短帧偶尔能通,长帧必错”。
3.4 发送 9 位数据:USART_SendData的掩码与 HAL 的分支
标准库的实现是USART2->DR = (Data & 0x01FF),只保留低 9 位。所以在 9 位字长 + 偶校验的配置下,你传0xA5,低 8 位进移位寄存器,第 9 位被硬件换成校验位,实际出线的是 8 个数据位加 1 个校验位,完全正确。
HAL 这边则有一个容易漏看的分支:只有当WordLength == UART_WORDLENGTH_9B且Parity == UART_PARITY_NONE时,HAL_UART_Transmit才把pData当成uint16_t*逐半字发送;一旦开了校验,它就走 8 位路径,按uint8_t*逐字节递增。也就是说,在 9B + 偶校验下你传一个uint16_t数组进去,HAL 会把高字节当成下一个独立字节发出去,帧数凭空翻倍。这个坑非常隐蔽,因为编译不报错,逻辑分析仪上也能看到“有数据”。
注意:带校验时数据宽度就是 8 位,接口参数该用
uint8_t,不要把 9 位字长误解成“每次要发 9 位数据”。
4. 接收路径:PE 标志、采样窗口和那句“先读 SR 再读 DR”
4.1 三取二表决:错误是在哪一刻被发现的
STM32F103 的 USART 采用固定的 16 倍过采样,没有可切换的 8 倍模式。接收端在检测到起始位下降沿后,把采样点定位到位周期的中央,并且不是只采一次,而是在第 8、9、10 个采样时钟上各采一次,做三取二多数表决——这就是所谓的噪声容限。表决结果既决定这一位的电平,也参与 NE(噪声)标志的判定。
校验是在整个数据字段移完之后算的:接收移位寄存器里凑齐 8 个数据位,硬件立刻用同样的异或逻辑算一遍期望的校验位,再和链路上收到的第 9 位比较,不一致就把 SR 的 PE 位置 1。这一位翻转发生在最后一个数据位之后、停止位之前,所以 PE 一定比 RXNE 早或同时到达。顺手区分一下三个容易混的标志:PE 是校验对不上,FE 是停止位没采到高电平(帧格式错),NE 是采样表决时三次结果不一致(线路噪声大)。三者互不替代。
4.2 PE / RXNE / ORE 挤在同一个 ISR 里的判定顺序
这一段是整个带校验接收的核心。手册规定的清标志序列是“先读 SR,再读 DR”,而且 PE、ORE、FE、NE 全都靠这同一个序列清除。这意味着你只有一次机会:SR 一读,状态快照就到手了;DR 一读,所有标志同时被清掉。顺序反过来,标志就丢了。
volatile uint32_t g_parity_err, g_ore_err, g_frame_err; void USART2_IRQHandler(void) { uint16_t sr = USART2->SR; /* 第一步:锁存状态快照,此时还没清任何标志 */ uint16_t dr = USART2->DR; /* 第二步:读 DR,序列完成,PE/RXNE/ORE/FE/NE 一起清 */ if (sr & USART_FLAG_PE) /* 校验错优先级最高:这帧数据不可信 */ { g_parity_err++; return; /* 直接丢,不要把 dr 送进缓冲区 */ } if (sr & USART_FLAG_ORE) /* 溢出:上一条还没被取走,这条已被覆盖 */ { g_ore_err++; return; } if (sr & USART_FLAG_FE) /* 帧错误:停止位不对,通常是波特率或接线问题 */ { g_frame_err++; return; } if (sr & USART_FLAG_RXNE) /* 到这里才是干净数据 */ { rx_push((uint8_t)(dr & 0x00FF)); /* 9 位字长,最高位是校验位,必须掩掉 */ } }三个参数值得说明:sr是一次性快照,不能反复去读USART2->SR来判断不同标志,因为读 DR 之后标志就没了;dr & 0x00FF的掩码是 9 位字长下的必需品;return一定要在读完 DR 之后,读 DR 这个动作本身就是清标志的一部分,任何“提前 return 不读 DR”的写法都会让中断反复触发,表现为系统卡死在中断里出不来。
4.3 只开 RXNEIE 不开 PEIE 会发生什么
这是血泪经验里最贵的一条。很多人想着“我只要收到数据就行”,只开了 RXNEIE。但校验错的时候,硬件依然把那帧数据搬进了 DR,RXNE 照样置位,中断照样进——只不过 PE 位也同时被置 1,而你的代码根本没看它。于是坏帧被当成好帧喂给协议解析层,出现“偶发的一帧数据莫名其妙全错,但设备不重启就一直错下去”的现象。
反过来,只开 PEIE 不开 RXNEIE 也不行:校验正确时 PE 不会置位,你的中断永远不进,数据全丢。正确做法是两个都开,并且在 ISR 里按上面的顺序判定。
4.4 DMA 接收下的校验错误:为什么不会自动丢包
用 DMA 收数据时,麻烦在于 DMA 的请求源是 RXNE,PE 完全不参与 DMA 的搬运决策。也就是说,一帧校验失败的字符照样会被 DMA 写进你的环形缓冲区,一个字节都不会少。你的代码在 DMA 传输完成中断里看到的是“长度正确的一帧”,只有重新按帧结构算一遍校验才能发现错误。
常见的两个解法:一是保留 PE 中断,在 ISR 里记一个“污染标记 + 当前写入位置”,DMA 完成后按位置区间把可疑数据剔除;二是不依赖硬件校验,在应用层做整帧校验(累加和或 CRC),硬件奇偶校验只作为“快速告警”使用。前者省 CPU,后者更可靠,实际项目里往往两个都留着。
提示:DMA 接收 + 硬件校验的组合,最怕的就是“看起来通了”。建议在自测阶段用信号发生器或另一块板子故意注入一位翻转,确认你的代码真的能发现它。
5. 避坑排查:串口2带奇偶校验最容易翻车的五个地方
5.1 配置阶段就该发现的两个坑
坑一:数据被悄悄截成 7 位。现象是发出0xA5收到0x25,或者 0x80 以上的值全部对不上,但 0x00~0x7F 一切正常。原因是USART_WordLength_8b配了USART_Parity_Even,校验位吃掉了帧的最高位,实际只剩 7 个数据位。解决办法是把字长改成USART_WordLength_9b,让校验位占用第 9 位;改完记得同步检查发送侧会不会误传uint16_t数组。
坑二:时钟挂在错误的总线,或者 GPIO 模式配错。现象是完全没有波形,或者 TX 有波形但 RX 一点反应都没有。原因通常是照抄了 USART1 的初始化,把RCC_APB1Periph_USART2写成了 APB2 的外设,或者把 RX 脚(PA3)配成了GPIO_Mode_AF_PP推挽输出。解决方法是核对时钟总线(USART2/3 在 APB1),RX 一律用GPIO_Mode_IN_FLOATING,并确认没有误开重映射(开了重映射,引脚会跑到 PD5/PD6 上去)。
5.2 跑起来才暴露的三个坑
坑三:校验错被静默吞掉。现象是长时间跑机后偶发一帧数据全乱,但错误计数器一直是 0。原因是只开了 RXNEIE 没开 PEIE,ISR 里也没检查 PE。解决办法是同时打开两个中断源,并在 ISR 里把 PE 放在最高优先级判定;同时加一个g_parity_err计数器输出到调试口,跑一晚看它涨不涨,这是最廉价的健康度指标。
坑四:ISR 里先读 DR 再读 SR,标志丢失。现象是校验错误计数器几乎不涨,但数据明显是坏的;或者中断进去出不来,主循环被饿死。原因是清标志序列写反了,DR 一读,PE 和 RXNE 同时清零,后面再读 SR 自然是干净的;另一种情况是某个分支提前return没有读 DR,RXNE 一直置位导致中断风暴。解决办法是严格按“先 SR 后 DR”写,且保证无论走哪条分支,DR 都被读到。
坑五:DMA 接收以为硬件会帮忙丢坏帧。现象是应用层偶尔解析出离谱的数值,重启后恢复,硬件校验计数却对不上。原因是 PE 不参与 DMA 搬运,坏字符进了缓冲区。解决办法是在 DMA 完成中断里按帧长和帧头重新校验一遍,或者用 PE 中断打“污染标记”再做区间剔除。
注意:这五个坑里有四个都不会导致编译报错,也不会导致“完全不通”,只会让数据“偶尔不对”。带校验的串口调试,第一件事应该是把错误计数器接出来,而不是盯着数据看。
6. 设计取舍:这 1 bit 到底值不值,什么时候该上 CRC
奇偶校验的能力边界非常清晰:它能 100% 发现一帧内的奇数个位翻转,对偶数个位翻转完全无能为力。两个相邻位同时被干扰翻转,校验和不变,链路安静如鸡。所以它从来不是“可靠性方案”,而是“廉价告警”。但它有一个别人比不了的优势——开销是每 11 个位时间多 1 个位,约 9%,而且是逐字符生效的:一旦某个字符校验失败,你立刻知道具体是哪个字节坏了,可以只丢这一字节,甚至在协议允许时请求重传该字节。
| 手段 | 开销 | 能发现 | 漏检情况 |
|---|---|---|---|
| 奇偶校验 | 1 bit / 11 bit ≈ 9% | 任意奇数个位翻转 | 任意偶数个位翻转 |
| 累加和 | 1~2 字节 / 帧 | 大部分随机错误 | 可相互抵消的组合 |
| CRC16 | 2 字节 / 帧 | 单/双/奇数位错误、≤16 位突发错误 | 概率极低 |
选择的分界线大致是这样:如果链路上主要威胁是“短促的电气干扰导致单 bit 翻转”,并且对端是老旧设备、协议帧长不固定、没法加尾部校验字段,
本文还有配套的精品资源,点击获取