STM32 USART 9位数据格式配置详解:从寄存器到HAL库实战 📅 发布时间:2026/8/29 21:28:14 👁 浏览次数: 上个月我在调一台老式工业仪表对方的通信协议就一句话“9位数据格式”。我一开始没当回事想着STM32的USART不外乎8个数据位把校验位打开就算9位帧了结果发出去的字节对方全不认。翻了一晚上参考手册才明白USART里那个M位一置位数据帧能跑到9个数据位更隐蔽的是HAL库里WordLength和Parity的组合方式直接影响第9位到底是“数据”还是“校验位”。这个问题遇到的工程师不少但真正说清楚的不多所以我干脆把寄存器、标准库、HAL库三条路径的配置方式都捋一遍顺便给一个能跑起来的485多机通信例子。这篇文章适合正在用STM32做串口项目、遇到9位协议、或者对WordLength/Parity关系一直模棱两可的人参考。1. 先说结论USART的9位格式到底长什么样1.1 从M位说起USART的帧结构不止8位很多人第一次看STM32参考手册的USART章节注意力都放在波特率、停止位、中断标志上对CR1寄存器里的M位基本是一扫而过。M位的功能是选择字长在STM32F1系列里M0是1个起始位8个数据位n个停止位M1则是1个起始位9个数据位n个停止位到了F4/H7这些高性能系列M位扩展成M[1:0]两位还能支持7位数据模式但9位格式一直是保留项。这里有个需要第一时间建立的认知串口通信的“帧”由起始位、数据位、校验位可选、停止位组成。我们常说的“8N1”就是8个数据位、无校验、1个停止位整个帧宽度是10位“9N1”则是9个数据位、无校验、1个停止位整个帧宽度是11位。USART配置成9位格式后数据寄存器DR/TDR/RDR里有效的就只有低9位第9位也就是bit8可以被当成普通数据位来读和写。1.2 “8位数据校验”和“9位纯数据”的区别这是一个超级容易混淆的点也是我在HAL库里踩坑的根源。很多人听到“9位格式”第一反应是“那我开个奇偶校验不就行了”因为8位数据1位校验线上看到的也是9位。但从协议语义上说这两种状态完全不同8位数据校验位有效数据是低8位第9位由硬件根据数据内容自动计算并填充应用程序不能自由控制这个bit8。9位纯数据有效数据是低9位bit8可以被应用程序手动置0或置1硬件不干预。换句话说如果你要的是“一个字节的真实数据一bit可由软件控制的标志位”那就必须配置成9位纯数据格式并且把校验功能关掉。如果你的目标仅仅是“提高数据可靠性检测传输错误”那用8位数据校验就够了没必要强行上9位格式。很多工程师把这两者混在一起最后出现“想用bit8当标志位但发出去之后bit8总是被硬件按奇偶校验计算覆盖”的问题。1.3 哪些场景真的需要9位格式我自己接触过的场景主要有三类大家可以对照判断自己是不是真的需要9位第一类是RS-485/RS-422多机通信。一条总线上挂多个从机主机发数据的时候如果所有从机都在接收就存在“这东西是不是发给我的”的识别问题。9位格式把bit8当成地址/数据标志位地址帧bit81数据帧bit80从机可以快速过滤。这种方案比“在数据流里约定一个特殊地址字节”更高效也不会出现地址字节和数据内容撞车的情况。第二类是某些专用仪器协议。一些电子衡器、扫码器、老式票据打印机、UPS监控卡设计协议时为了在一条线上同时传地址和数据直接采用了9个数据位的物理帧。这种设备你对PC串口时往往发现普通串口助手根本选不了9位只能想办法用STM32这类MCU去对接。第三类是8位数据奇偶校验的变体。严格说它不算“9位纯数据”但物理帧确实是9位。不少行业设备要求“8位数据偶校验”配置成8E1就是9位帧。理解这一点能帮你把“校验”这件事从寄存器层面看透。2. 寄存器视角9位数据是怎么被读写的2.1 CR1寄存器关键位配置如果想彻底弄懂9位格式寄存器操作是绕不开的。以STM32F1为例USART_CR1里和帧格式相关的位有这几个位名称作用bit12M字长选择08位数据19位数据bit10PCE校验使能0关闭校验1使能校验bit9PS校验选择0偶校验1奇校验bit5RXNEIE接收缓冲区非空中断使能bit3TXEIE发送缓冲区空中断使能要注意的是M和PCE是独立的两个开关。M1只是把帧的数据位拉长到9位而PCE1才会让硬件介入计算校验位。如果M1, PCE0第9位完全归软件管如果M1, PCE1硬件认为你用的是“9位数据校验”的10位帧但实际有效数据位会被压缩到8位第9位作为校验位使用。所以寄存器配置时务必根据真实需求决定PCE的值。2.2 发送/接收9位数据的代码基础寄存器模式下发送9位数据比HAL库直观得多。数据寄存器DR是16位宽的发送时写入低9位读取时低9位有效// USART1 配置为 9N1假定时钟已经初始化 void USART1_Init_9N1(uint32_t baudrate) { RCC-APB2ENR | RCC_APB2ENR_USART1EN; // GPIO配置PA9TX, PA10RX复用推挽/浮空输入 GPIOA-CRH ~(0xFFU 4); GPIOA-CRH | (0x0BU 4); // PA9 复用推挽输出 GPIOA-CRH | (0x04U 8); // PA10 浮空输入 USART1-BRR 72000000U / baudrate; USART1-CR1 USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_M; // 注意 CR1_M 就是 9 位模式 } void USART1_Send9(uint16_t data) { while (!(USART1-SR USART_SR_TXE)); USART1-DR data 0x01FFU; // 只保留低9位 } uint16_t USART1_Receive9(void) { while (!(USART1-SR USART_SR_RXNE)); return (uint16_t)(USART1-DR 0x01FFU); }接收中断里也一样读DR时bit8就是第9位数据。如果想在中断里区分地址帧和数据帧判断方法是void USART1_IRQHandler(void) { uint16_t frame; if (USART1-SR USART_SR_RXNE) { frame (uint16_t)(USART1-DR 0x01FFU); if (frame 0x0100U) { // bit81地址帧 } else { // bit80数据帧 } } }这种写法最干净也最容易理解。如果你不想跟HAL库的缓冲区布局纠缠寄存器方案是调试9位格式的优先选择。2.3 第9位能干什么地址帧/数据帧的经典玩法既然第9位可以被软件自由控制最经典的用法就是做多机通信的地址/数据标志。协议可以设计成主机发送一个地址帧bit81低8位是从机地址然后发送一串数据帧bit80低8位是实际数据。每个从机都能收到地址帧但只有地址匹配的从机才继续处理后续的数据帧。我在实际项目中就用过这种方案控制一主三从的RS-485伺服系统。地址帧和数据帧都走同一个USART硬件从机中断里先判断bit8再决定是比对地址还是搬运数据。这个逻辑用标准库或寄存器实现非常简单接收到的每个9位数据直接就是一个uint16_t不需要额外的拆字节处理。3. HAL库的9位模式WordLength、Parity与缓冲区3.1 配置组合的正确姿势HAL库把寄存器的M位抽象成了WordLength把PCE抽象成了Parity。但它的文档表述有误导性很多人按字面意思配置完就出问题。我先把几种合法组合列成表格这是9位模式在HAL库下的“标准答案”实际想要的物理帧WordLengthParity说明8N18位数据无校验UART_WORDLENGTH_8BUART_PARITY_NONE最常用8E1/8O18位数据校验UART_WORDLENGTH_8BUART_PARITY_EVEN / ODD物理帧9位第9位是硬件校验9N19位数据无校验UART_WORDLENGTH_9BUART_PARITY_NONE第9位可软件控制重点9位校验UART_WORDLENGTH_9BUART_PARITY_EVEN / ODDHAL处理时会按8位有效数据处理日常不建议关键规则就一句想要真正自由的9位数据格式必须同时满足WordLength UART_WORDLENGTH_9B和Parity UART_PARITY_NONE。只要Parity不是NONE硬件就会用第9位做奇偶校验你写入的bit8会被覆盖。3.2 发送和接收API背后的字节布局陷阱HAL库的HAL_UART_Transmit和HAL_UART_Receive函数签名里数据指针都是uint8_t *这就给9位模式埋了一个大坑。在9位纯数据模式下每个有效数据是9位没法用1个字节装下。HAL库内部会把pData指针按uint16_t *解释每个9位字占用缓冲区里的2个字节第一个字节存低8位第二个字节的第0位存bit8其余位忽略。而函数的Size参数在9位模式下表示的是“9位数据的个数”不是字节数。举个例子我想一次发送三个9位数据0x000、0x100、0x1FF正确的写法是UART_HandleTypeDef huart1; // 初始化结构体 huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_9B; 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); uint16_t txData[3] {0x000, 0x100, 0x1FF}; HAL_UART_Transmit(huart1, (uint8_t *)txData, 3, 1000);这里txData是uint16_t数组3个元素占了6个字节但Size传3。如果谁按8位习惯传了6HAL会认为要发送6个9位字实际会读越界轻则发出去的数据错乱重则HardFault。接收方向同理uint16_t rxData[32]; HAL_UART_Receive_IT(huart1, (uint8_t *)rxData, 32);回调HAL_UART_RxCpltCallback触发后rxData里每个元素都是一个9位接收值bit8是否置位直接反映了对方发来的第9位状态。缓冲区大小按元素个数算不用自己乘2。3.3 中断/DMA/空闲中断在9位模式下的适配9位模式下使用中断接收和DMA接收总体思路一样但缓冲区类型必须从uint8_t[]换成uint16_t[]。很多人在“串口接收不定长数据”的场景里写了空闲中断DMA代码对8位模式没问题一换9位模式就收上来一堆错位数据原因就是缓冲区布局没跟着变。// 接收不定长9位数据帧空闲中断 DMA #define RX_BUF_SIZE 128 uint16_t rxBuf[RX_BUF_SIZE]; volatile uint8_t idleFlag 0; void MX_USART1_UART_Init(void) { // 初始化同上9N1 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, (uint8_t *)rxBuf, RX_BUF_SIZE); } void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); idleFlag 1; HAL_UART_DMAStop(huart1); // 停止本次DMA准备处理一帧 } }在数据处理函数里不要再用处理字符串的方式遍历rxBuf而是按16位元素去取void ProcessRxFrame(void) { uint16_t element; for (uint16_t i 0; i 32; i) { element rxBuf[i]; if (element 0x0100U) { // bit81属于地址/标志帧 } else { // bit80普通数据 } } // 清空计数器并重启DMA }需要注意DMA传输时外设寄存器宽度也要和9位模式匹配。HAL库在初始化时会根据WordLength设置DMA的数据宽度正常情况下不需要手动干预但如果你是自己写DMA配置要确保外设数据宽度和内存数据宽度一致通常是半字。4. 实操案例一个可实现的多机485通信示例4.1 协议设计纸上谈兵没用我拿一个真实跑通的例子来演示。场景一个主机、两个从机通过RS-485总线连接使用9位数据格式。帧定义如下地址帧bit81低8位是从机地址1~247。数据帧bit80低8位是有效数据。一次完整交互主机先发1个地址帧然后连续发若干数据帧最后从机回传自己的状态。从机平时虽然也接收所有帧但只根据bit8来筛选只有地址帧且低8位和自己地址相同才进入“数据采集模式”后续收到的数据帧才被接受如果地址帧不是发给自己的则忽略之后的数据帧直到下一个地址帧。4.2 主机/从机代码骨架寄存器方式写起来最简单主机发送一段数据// 主机发送向地址为5的从机发送3个数据字节 void Master_SendToSlave(uint8_t slaveAddr, uint8_t *data, uint8_t len) { USART1_Send9((uint16_t)slaveAddr | 0x0100U); // 地址帧bit81 for (uint8_t i 0; i len; i) { USART1_Send9(data[i]); // 数据帧bit80 } }从机接收中断里这样处理uint8_t localAddr 0x05; uint8_t addrMatched 0; uint8_t rxDataBuf[16]; uint8_t rxDataCnt 0; void USART1_IRQHandler(void) { uint16_t frame; if (USART1-SR USART_SR_RXNE) { frame (uint16_t)(USART1-DR 0x01FFU); if (frame 0x0100U) { // 地址帧 if ((frame 0x00FFU) localAddr) { addrMatched 1; rxDataCnt 0; } else { addrMatched 0; } } else { // 数据帧 if (addrMatched rxDataCnt 16) { rxDataBuf[rxDataCnt] (uint8_t)frame; } } } }这套逻辑在485总线上非常稳从机不需要额外判断“一帧结束”的定时器因为地址帧就是天然的帧头。主机发送时注意RS-485收发器的方向脚发送前置高方向发送完再拉低进入接收模式。4.3 实测数据与波形观察实际用逻辑分析仪抓到的9位帧波形数据结构是1个起始位然后是从低位到高位的9个数据位第9位就是bit8最后是1个停止位。用逻辑分析仪解码时需要把“数据位”参数设置为9无校验停止位1这样解码出来才能直接看到地址/数据标志。如果你用默认的8位解码整个数据会整体错移看起来非常奇怪。我自己实测过一组数据发送地址帧0x105低8位5bit81接着发送数据帧0x55、0xAA、0x00。逻辑分析仪解码结果分别是地址帧解出0x105数据帧解出0x55、0xAA、0x00。第9位的1/0状态在波形上清晰可见正好对应帧头标记。4.4 如果要用HAL库跑同一套逻辑用HAL库时从机接收中断可以这样写uint16_t rxFrame[1]; // 每次接收一个9位字用中断方式 void StartReceive(void) { HAL_UART_Receive_IT(huart1, (uint8_t *)rxFrame, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint16_t frame rxFrame[0]; // 帧处理逻辑与寄存器版本完全相同 if (frame 0x0100U) { // 地址帧分支 } else { // 数据帧分支 } StartReceive(); // 重新启动下一次接收 } }用HAL库时rxFrame必须是uint16_t别写成uint8_t。我见过不少人在这里直接沿用8位模式的数据类型结果回调里拿到的值全是错乱的。5. 最容易被坑的细节排查5.1 HAL卡死、数据错乱这类问题的定位思路结合群里和论坛上常见的提问9位模式下的问题基本集中在几个原因上。如果你遇到以下现象按顺序排查现象配置了UART_WORDLENGTH_9B但第9位始终发不对。 排查先检查Parity是否设成了UART_PARITY_NONE。只要校验开着第9位就是硬件校验位跟你的软件设置无关。这是最高频的问题优先级第一。现象HAL_UART_Transmit调用后程序卡在等待TXE标志。 排查检查Size是否按“9位字的个数”传入而不是字节数检查pData缓冲区是否至少为Size * 2字节。缓冲区太小HAL库内部会越界读数据导致发送状态机混乱。现象DMA接收频繁触发错误中断或者接收数据错位。 排查确认DMA内存数据宽度是否设置成了半字。HAL库一般自动处理但如果你用CubeMX生成工程后手动改过DMA参数很容易改错。现象接收到的数据bit8不稳定有时是0有时是1且没有规律。 排查确认总线上所有节点的校验、停止位配置完全一致。很多工业设备默认是8E1物理帧也是9位但bit8是校验位不是数据位混在一起配置必然乱。5.2 用逻辑分析仪验证9位帧强烈建议调试9位格式时用逻辑分析仪比串口助手直观得多。设置好解码参数后你能直接看到每一位的电平还能检查停止位是否正常。我习惯的做法是先发一组已知数据比如0x100然后看解码结果是不是0x100同时观察波形的第9位是否为高电平。有一点要提醒很多PC串口调试助手不支持9位数据位设置因为它们封装了Windows/串口芯片驱动层面的8位限制。想用PC端验证9位协议要么选择支持原始数据的串口调试软件要么干脆用两块STM32对发。我自己的项目里基本都是第二块STM32扮演“协议网关”一头接9位总线的设备另一头用普通8N1通过USB转串口接PC这样调试效率最高。5.3 9位格式的边界与兼容性提醒使用9位格式前还要想清楚整个链路里每一处都支持它普通USB转串口芯片如CH340、CP2102在PC端驱动里默认不支持9数据位需确认驱动和终端软件是否透传支持。逻辑分析仪解码器能不能配置9位没有的话就没法直接观察。如果链路中间有光耦隔离、RS-485收发器它们只管电平转换不影响帧格式但传播延迟会对波特率上限有影响9位模式本身并不特殊。如果产品需要和第三方设备对接务必先确认对方的“9位”到底是指9N1还是有校验的9E1。我曾经就因为对方说“9位”但实际底层是8E1白调了一天。5.4 一个罕见的边界问题7位/9位切换导致数据寄存器残留在F4/H7系列上USART的M[1:0]支持7位模式。如果程序动态切换7位和9位模式数据寄存器里可能残留上次的bit8。我遇到过一次从9位模式切到8位模式后读到的第一个数据bit8居然是上一次帧的残留值。虽然这是极端场景但建议切换帧格式后先清一次接收缓冲或者重新初始化USART避免脏数据进来。6. 9位格式与常见的串口应用怎么衔接6.1 不定长数据接收9位模式下的处理差异很多工程喜欢用空闲中断DMA去接收“不定长数据”这套思路在9位模式下也完全成立但要注意一个关键点空闲中断判断的是“总线上没有电平跳变的时间长度”和字长是8位还是9位无关所以帧边界检测逻辑不用改。要改的是数据缓冲区类型以及后续解析数据的步长。8位模式下一帧数据是N个字节9位模式下一帧数据是N个16位单元。解析时如果继续按uint8_t遍历会把每个9位字的低字节和高字节拆开处理结果自然不对。6.2 和485伺服等工业控制的结合针对热词里提到的“STM32控制伺服电机485”如果采用的是RS-485半双工总线9位格式的优势非常明显。以一主多从伺服控制为例主机可以周期性发送地址帧指定当前要控制的从机紧接着的多个数据帧就是目标位置、速度、命令字等。从机通过bit8判断地址帧和命令帧不必像8位协议那样在数据内容里约定起始符和地址表效率更高也不容易被数据内容干扰。当然如果整套系统已经用了Modbus RTU这类成熟协议没有必要为了用9位而9位。9位格式更适合那些“从机数量多、通信周期快、协议定制可控”的场合。6.3 调试PID参数这类高频调参场景调试PID这类场景下最常见的交互模式是PC端上位机发“读取/设置参数”的命令STM32回传数据。这种交互用8N1足够了。但如果把9位格式作为底层总线协议建议把“串口逻辑层”和“业务层”分开底层收发只管报文的完整性上层再解析地址、数据、CRC。我自己的习惯是维护一个简单的环形缓冲缓冲元素就是uint16_t接收中断往里面丢业务层按帧解析。这样无论底层是9位还是8位上层代码都能保持稳定。7. 实测下来我的一些感受经过这几个项目的折腾我对STM32 USART 9位数据格式的结论是硬件本身完全支持而且用寄存器或者标准库操作都非常顺手真正的复杂度全部集中在HAL库的封装上。如果你只是想在项目里临时验证一下9位通信是否可行我建议直接上寄存器操作几分钟就能跑通如果你需要工程化落地再考虑HAL库的缓冲区布局和DMA配置。几个小建议也算是我踩坑换来的经验配置USART前把结构体清零或者用CubeMX重新生成代码。HAL库的结构体如果没初始化干净残留的Parity位会让你的9位模式变成8位校验。所有通信节点必须统一帧格式9N1就是9N18E1就是8E1不要觉得“反正都是9位帧”就能混用。协议双方对第9位的解释不同数据就是垃圾。先用固定值测试比如循环发送0x000、0x001、0x100、0x1FF看接收端收到的值是否一一对应。这组数据能同时验证低8位和bit8的正确性。遇到HAL库卡死不要急着怀疑硬件先查Size和缓冲区大小这是低概率出问题但高概率被忽视的地方。如果项目对实时性要求不高我更乐意用轮询发送中断接收的方式做9位通信DMA虽然高效但在9位模式下缓冲区错位的问题排查成本更高。最后再分享一个我自己的小习惯工程里凡是涉及USART字长配置的地方我都会加一行注释写清楚“当前配置是9位数据还是8位数据校验”。项目一大回头改代码的人很容易在这上面踩坑一行注释能省一整天的排查时间。