☰
嵌入式USART串口通信从入门到精通:原理、配置与实战避坑指南
2026/10/18 1:01:45 网站建设 项目流程

1. 从点灯到通信:为什么USART是绕不开的第一道坎

刚接触嵌入式开发那会儿,我总觉得点个LED、读个按键就已经算是入门了。直到某天导师扔过来一块板子,说“把传感器数据传到电脑上看看”,我才发现事情没那么简单。串口,也就是USART,成了横在面前的第一座山。你可能会问,都什么年代了,以太网、无线通信满天飞,为什么还要死磕这个看起来有点“古老”的接口?答案很简单:它是调试的命根子,是设备对话的普通话,更是理解所有通信协议的活教材。

USART全称Universal Synchronous/Asynchronous Receiver/Transmitter,翻译过来叫通用同步/异步收发器。名字听着唬人,拆开看就三件事:通用意味着它不挑食,什么数据都能传;同步/异步说明它有两种工作模式,但九成九的场景我们只用异步模式;收发器就是它负责把并行数据变成串行比特流发出去,再把收到的比特流拼回并行数据。你可以把它想象成一个特别靠谱的翻译官,MCU内部是并行世界,外部线路是串行世界,USART就在中间做同声传译。

那它到底解决了什么问题?最直接的场景就是调试信息输出。代码跑飞了,你总得知道飞到哪一步了吧?在USART出现之前,有人用LED闪烁次数来编码错误码,闪三下代表这个错,闪五下代表那个错,效率低得令人发指。有了USART,直接printf往电脑一扔,变量值、函数调用栈、时间戳全出来了。另一个核心场景是设备间通信,比如主控MCU和蓝牙模块、GPS模块、指纹模块之间的对话,基本都是USART打头阵。这些模块往往只需要简单的指令交互,USART的简洁性反而成了优势——不需要复杂的协议栈,几根线一接就能通。

这篇文章适合谁看?如果你是刚学STM32或者ESP32的新手,正在被“波特率”“中断”“DMA”这些词搞得头晕,那这篇就是为你写的。如果你已经用过串口但总是遇到乱码、丢数据的问题,这里也有排查思路。甚至如果你是从Arduino转过来的,想搞清楚底层到底发生了什么,我也会把寄存器层面的逻辑讲清楚。我的目标很简单:让你看完就能动手配一个稳定的串口,并且知道每一步为什么要这么做。

2. 异步通信的底层逻辑:没有时钟线怎么对齐

2.1 起始位、数据位、校验位、停止位到底怎么排的

异步通信最反直觉的地方在于:收发双方没有共享时钟线。那接收方怎么知道每一位持续多久?答案是事先约定好波特率。波特率就是每秒传输的码元数,单位是bps。比如9600波特率意味着每一位持续1/9600秒,大约104微秒。发送方按照这个节奏往外吐比特,接收方按照同样的节奏去采样。这就像两个人约好每隔一秒说一个字,虽然没有节拍器,但各自心里默数,只要手表走得一样准,就能对上。

但光有波特率还不够,因为数据不是连续不断的,总得有个开头和结尾。于是就有了帧格式。一个典型的USART帧长这样:空闲时线路是高电平,突然拉低一个位的时间,这就是起始位,告诉接收方“注意,我要开始发了”。接收方检测到这个下降沿,就知道接下来要按约定节奏采样了。紧接着是数据位,通常是8位,从最低位开始发。你可以选择5到9位,但8位是绝对主流,因为一个字节刚好8位。数据位之后是校验位,可选奇校验、偶校验或者无校验。校验位的逻辑很简单:如果选偶校验,那么数据位加校验位中1的个数必须是偶数;奇校验则必须是奇数。接收方一算,发现不对,就知道传输过程中有比特翻转了。最后是停止位,把线路拉回高电平,长度可以是1位、1.5位或2位,通常用1位。

把这些串起来,一个最常见的配置就是9600-8-N-1:波特率9600,8位数据,无校验,1位停止位。总共10位,传一个字节实际占用10位的时间。如果你要传1000个字节,那就是10000位,9600波特率下大约需要1.04秒。这个计算在评估通信耗时的时候非常有用。

2.2 波特率误差为什么会导致乱码

理论上收发双方波特率一致就能通,但实际中晶振有误差,分频系数有取整误差,累积起来就会导致采样点偏移。接收方一般在每一位的中间时刻采样,这样容错空间最大。如果波特率偏差太大,采样点就会逐渐偏离位中心,最终采到相邻位上去,数据就全乱了。

那误差允许多少?通常要求总误差小于3%到5%,具体取决于数据位长度和采样机制。很多MCU采用16倍过采样,也就是每个位采样16次,取中间第8、9、10次的三取二判决,这样能容忍的误差更大一些。但如果你用内部RC振荡器做时钟源,温漂可能就有±2%,再加上分频误差,很容易超标。所以用USART时尽量选外部晶振,这是血泪教训。我见过太多人用内部HSI跑串口,常温下勉强能通,一到冬天或者夏天就疯狂出乱码,查半天以为是代码问题,其实是时钟在飘。

计算波特率分频值有个公式:USARTDIV = fCK / (16 * 波特率)。假设系统时钟72MHz,要9600波特率,那USARTDIV = 72000000 / (16 * 9600) = 468.75。整数部分是468,小数部分0.75乘以16等于12,所以BRR寄存器写0x1D4C。实际波特率就是72000000 / (16 * 468.75) = 9600,误差为零。但如果系统时钟是25MHz,算出来USARTDIV = 162.76,取整后实际波特率会有约0.15%的误差,这完全可以接受。关键是要养成算一下误差的习惯,别等到出问题了才回头查。

2.3 全双工、半双工和单工到底怎么选

USART支持全双工,意味着收发可以同时进行,因为TX和RX是两根独立的线。全双工的好处是效率高,你发指令的同时可以收数据,不需要切换方向。但有些场景为了省线或者省引脚,会把它配成半双工,也就是TX和RX合并成一根线,分时复用。半双工需要软件控制方向切换,麻烦不说,还容易冲突。除非引脚实在不够,否则一律用全双工。

单工就更少见了,要么只发要么只收。比如某些传感器只往外吐数据,那MCU这边就只需要配RX。这种场景下把TX引脚省出来做GPIO用,也算是一种资源优化。但要注意,即使你只用RX,USART的发送部分也得初始化,否则某些MCU会进不了接收状态,这是硬件设计上的坑。

3. 从寄存器到库函数:手把手配一个能用的串口

3.1 引脚、时钟、波特率:初始化三件套

不管你用标准库、HAL库还是直接怼寄存器,初始化USART都绕不开三件事:开时钟、配引脚、设参数。以常见的STM32F103为例,假设我们用USART1,TX是PA9,RX是PA10。

第一步,开时钟。USART1挂在APB2总线上,GPIOA也挂在APB2上。所以RCC->APB2ENR |= RCC_APB2ENR_USART1EN | RCC_APB2ENR_IOPAEN;。这一步经常被忘记,结果就是寄存器写不进去,读回来全是零。我刚开始学的时候,对着手册查了半天为什么BRR写不进去,最后发现是时钟没开,那种感觉真是想砸键盘。

第二步,配引脚。PA9要配成复用推挽输出,因为TX是MCU往外驱动;PA10要配成浮空输入或者上拉输入,因为RX是接收外部信号。在F1系列里,CRL寄存器控制低8位引脚,PA9和PA10在CRH里。具体操作是:GPIOA->CRH &= ~(0xFF << 4); GPIOA->CRH |= (0x0B << 4) | (0x04 << 8);。0x0B表示复用推挽输出50MHz,0x04表示浮空输入。如果你用的是F4或者H7系列,引脚配置在MODER、OTYPER、OSPEEDR、PUPDR这几个寄存器里,逻辑类似但更清晰。

第三步,设参数。先算好BRR的值写进去,然后配置CR1寄存器:使能USART、使能发送、使能接收、使能接收中断(如果需要的话)。USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;。注意UE位要最后置1,否则配置可能不生效。这个顺序问题在参考手册里有明确说明,但很多人不看,直接一把梭,结果就是时好时坏。

3.2 发送一个字节为什么要等TXE标志

发送数据看起来简单:把字节写进DR寄存器就完事了。但实际代码里总能看到while(!(USART1->SR & USART_SR_TXE));这样的等待。TXE是“发送数据寄存器空”标志,置1表示DR里的数据已经被搬到移位寄存器了,你可以写下一个字节了。如果不等待直接写,就会覆盖掉上一个还没发完的数据,导致丢字节。

这里有个细节:TXE置1只代表数据从DR搬到了移位寄存器,并不代表移位寄存器已经发完。真正发完要看TC标志。如果你在发送完最后一个字节后立刻关闭USART或者进入低功耗,必须等TC置1,否则最后一个字节可能没发出去。我踩过这个坑:发完AT指令立刻切电源,模块根本没收到,因为最后一个字节还在移位寄存器里没出去。

用中断发送的话,流程是:使能TXEIE,在中断服务函数里判断TXE,然后写下一个字节。当所有字节发完,关闭TXEIE。这样CPU不用死等,效率高很多。但要注意中断优先级和临界区保护,如果发送缓冲区是全局的,主循环和中断都在操作,就得加锁或者用环形缓冲区。

3.3 接收中断和空闲中断的配合使用

接收比发送麻烦,因为你不知道对方什么时候发、发多少。最简单的办法是轮询RXNE标志,收到一个读一个。但这样CPU利用率极低,而且如果对方发得快,你处理得慢,就会溢出。所以实际项目里基本都用接收中断:每收到一个字节进一次中断,把数据存到缓冲区里。

但问题来了:你怎么知道一帧数据收完了?如果协议是固定长度还好,收够N个字节就处理。但很多协议是变长的,比如AT指令的响应,以\r\n结尾,长度不固定。这时候空闲中断就派上用场了。空闲中断的触发条件是:总线在接收完一个字节后,保持空闲状态超过一个字节的时间。利用这个特性,你可以在接收中断里往缓冲区塞数据,在空闲中断里置一个标志,告诉主循环“一帧收完了,可以处理了”。

这个组合拳我用了无数遍,稳定得不行。具体配置是:使能RXNEIE和IDLEIE,在USART中断服务函数里判断这两个标志。收到数据就存,检测到空闲就置标志并清IDLE标志。注意清IDLE标志的顺序:先读SR再读DR,这是手册里明确要求的,顺序反了清不掉。

4. 那些年我们踩过的串口坑:问题排查实录

4.1 乱码问题排查表

乱码是串口调试中最常见的问题,没有之一。原因五花八门,我整理了一个排查表,按可能性从高到低排列:

现象可能原因排查方法
全是乱码,一个可读字符都没有波特率不匹配检查双方波特率是否一致,用示波器测位宽
偶尔出现乱码,大部分正常时钟误差偏大或干扰换外部晶振,检查地线是否接好
前几个字节正常,后面全乱接收溢出或缓冲区越界检查接收中断优先级,增大缓冲区
特定字符总是错,比如0x00校验位或停止位配置错误确认双方帧格式完全一致
上电初期乱码,过一会正常晶振起振慢或电源不稳增加上电延时,检查电源纹波

波特率不匹配是最常见的。很多人用CH340或者CP2102这类USB转串口芯片,电脑端驱动默认波特率可能和代码里设的不一样。我习惯在代码开头先发一串\r\n\r\n,如果电脑收到的是乱码,那基本就是波特率问题。另一个隐蔽的坑是数据位和停止位:有些模块默认是7位数据、2位停止位,你按8-N-1去配,收到的数据就总是差一位。

4.2 数据丢失的三种典型场景

数据丢失比乱码更隐蔽,因为收到的数据看起来是对的,只是少了几个字节。第一种场景是接收溢出:中断服务函数执行时间太长,下一个字节已经来了还没读走DR,ORE标志置1,数据就丢了。解决办法是缩短中断服务函数,只做存缓冲区的操作,复杂处理放到主循环。如果波特率很高,比如115200以上,可以考虑用DMA接收。

第二种场景是发送时没等TXE。前面说过,连续写DR会覆盖。有些人用printf重定向到串口,printf内部是循环调用发送函数的,如果发送函数没等TXE,打印长字符串时就会丢字符。标准库的printf重定向通常写成while(!(USART1->SR & USART_SR_TXE)); USART1->DR = ch;,这个等待不能省。

第三种场景是中断优先级冲突。如果USART中断优先级低于某个频繁触发的定时器中断,USART中断就会被延迟响应,导致溢出。我一般把USART接收中断设为次高优先级,仅次于系统滴答定时器,这样既能保证实时性,又不会打断关键时序。

4.3 电平不匹配导致的通信失败

USART是TTL电平,高电平通常是3.3V或5V。如果你直接把它接到RS232接口上,那肯定通不了,因为RS232用负逻辑,-3V到-15V表示1,+3V到+15V表示0。中间需要电平转换芯片,比如MAX3232。反过来,如果你把两个不同电压域的MCU直接连USART,比如一个3.3V一个5V,3.3V的TX接5V的RX,5V那边可能识别不了3.3V的高电平,导致通信失败。这时候要么加电平转换,要么选容忍5V输入的引脚。

还有一种情况是共地问题。两个设备只连了TX和RX,没连GND,信号就没有参考点,通信时好时坏。我见过有人用电池供电的模块和电脑通信,忘了接地线,结果数据全是乱的。记住:任何通信,地线都是第一根要接的线。

5. 进阶玩法:DMA、流控和协议设计

5.1 DMA接收为什么能解放CPU

当波特率达到115200甚至更高时,每个字节间隔只有几十微秒,中断方式虽然能用,但CPU频繁进出中断也是不小的开销。这时候DMA就派上用场了。配置DMA接收的思路是:让DMA在后台把DR寄存器的数据搬到内存缓冲区,CPU完全不用管。你只需要在空闲中断里检查DMA还剩多少个没搬,就能算出收到多少字节。

具体配置步骤:使能DMA时钟,配置DMA通道的源地址为&USART1->DR,目标地址为接收缓冲区,传输方向为外设到内存,循环模式或者普通模式。然后在USART的CR3寄存器里使能DMAR位。这样每收到一个字节,DMA自动搬走,完全不占CPU。空闲中断触发时,用缓冲区总大小 - DMA剩余传输数量就是实际收到的字节数。

这个方案我用来接收GPS模块的数据,波特率9600,每秒几十个字节,CPU占用率几乎为零。但要注意DMA传输完成中断和空闲中断的配合,以及缓冲区对齐问题。如果缓冲区是uint8_t数组,DMA配置成字节传输就行;如果是uint32_t数组,就得配成字传输,但USART的DR是8位的,所以还是用字节传输最省心。

5.2 硬件流控RTS/CTS什么时候必须用

硬件流控是防止接收溢出的终极手段。原理很简单:接收方准备好接收时,把RTS拉低;发送方检测到CTS为低才发送。这样接收方缓冲区快满的时候,拉高RTS,发送方就暂停。听起来很美好,但实际项目中用的人不多,因为要多接两根线,而且很多USB转串口模块不支持流控。

那什么时候必须用?当发送方发得飞快,接收方处理不过来,而且软件流控(XON/XOFF)又不能用的时候。比如两个MCU之间高速传输大量数据,接收方还要做Flash写入,写入期间不能接收,这时候硬件流控就是救命的。但如果你只是调试打印,或者和传感器模块通信,数据量小,那完全没必要。

配置流控需要设置CR3寄存器的RTSE和CTSE位,并且把对应的引脚配成复用功能。注意RTS和CTS的极性,不同MCU可能不一样,查手册确认。

5.3 自定义帧格式的四个设计要点

当你用USART传输自定义协议时,帧格式设计得好不好,直接决定了通信的可靠性和解析的难易度。我总结四个要点:

第一,帧头要足够独特。用0xAA、0x55这种交替模式做帧头,不容易和数据混淆。如果数据里也可能出现0xAA,那就用双字节帧头,比如0xAA 0x55,概率就低多了。

第二,长度字段要明确。帧头之后跟一个长度字节,表示后续数据有多少字节。这样接收方先收帧头,再收长度,然后按长度收数据,逻辑清晰。长度字段本身要包含校验,防止长度字节出错导致整个帧解析错位。

第三,校验方式要匹配场景。简单的和校验(所有字节相加取低8位)够用,但检错能力弱。CRC16检错能力强,但计算量大。如果MCU资源紧张,和校验也能凑合;如果对可靠性要求高,老老实实上CRC。

第四,超时机制不能少。即使有长度字段,如果传输过程中丢了一个字节,接收方会一直等下去。所以需要一个超时定时器,比如50ms没收到新字节就重置接收状态机,等待下一个帧头。

6. 从USART到其他协议:一通百通的底层思维

6.1 USART和I2C、SPI的本质区别

学完USART再看I2C和SPI,你会发现它们解决的是不同维度的问题。USART是异步的,没有时钟线,靠波特率对齐;I2C和SPI是同步的,有时钟线,收发双方严格按时钟节拍走。同步的好处是速率可以很高,SPI随便上几十MHz,I2C标准模式100kHz,快速模式400kHz,高速模式3.4MHz。异步的好处是省线,两根线就能全双工。

另一个区别是拓扑结构。USART是点对点的,两个设备之间直连。I2C是总线式的,一根线上可以挂多个从设备,靠地址区分。SPI是主从式的,每个从设备一根片选线,硬件上比I2C多几根线,但协议更简单,没有应答机制,全速跑就行。

理解了这些区别,你就能根据场景选协议:点对点、中低速、省线选USART;多设备、中速、省引脚选I2C;高速、短距离、多设备选SPI。没有哪个最好,只有哪个最合适。

6.2 同步模式下的USART能干什么

USART的同步模式经常被忽略,因为它默认是异步的。但同步模式其实很有用:它把TX引脚变成时钟输出,RX变成数据线,相当于一个简化的SPI。你可以用它来驱动一些需要时钟同步的芯片,比如某些ADC或者移位寄存器。同步模式的配置比异步多一步:设置CR2寄存器的CLKEN位,然后配置CPOL和CPHA决定时钟极性和相位。

但说实话,同步模式用的人很少,因为SPI硬件更专业,配置也更灵活。USART的同步模式更像是一个“附赠功能”,在引脚紧张的时候可以顶一下。如果你真的需要同步通信,优先选SPI,除非那个MCU没有SPI外设。

6.3 用USART理解所有通信协议的共性

最后我想说,USART是理解通信协议的最佳切入点,因为它足够简单,又包含了所有通信协议的核心要素:帧格式、同步机制、差错控制、流量控制。你把这四个概念在USART上搞明白了,再看Modbus、CAN、TCP,会发现它们只是在这些要素上做了不同的取舍和扩展。

比如Modbus RTU就是基于USART的,帧格式里加了地址、功能码、CRC,本质上还是串口那一套。CAN总线虽然物理层不同,但帧结构里的仲裁段、控制段、数据段、CRC段,逻辑上和USART帧是相通的。TCP的滑动窗口、重传机制,对应到USART就是流控和校验重发。底层思维一通,上层协议百通,这是我做了这么多年嵌入式最深的体会。

所以别小看USART,它不只是个调试工具,更是你理解整个通信世界的钥匙。把它的每一个细节都吃透,后面学什么协议都会觉得似曾相识。我在实际项目中用USART最频繁的场景还是调试输出和模块通信,但每次遇到新协议,我都会先想想它在USART的基础上加了什么、减了什么,这样理解起来特别快。如果你正在学通信协议,我的建议是:先把USART玩到滚瓜烂熟,再去看别的,你会发现一切都顺理成章。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询