☰
UART串口通信全解析:从STM32到FPGA的实战指南
2026/10/7 2:04:07 网站建设 项目流程

1. 为什么UART是嵌入式工程师的"第一课",也是最后一课

搞嵌入式这行十来年,如果让我选一个最能体现"基本功"的外设,我会毫不犹豫地选UART。它简单到两根线就能跑通,又复杂到能让工作五年的老手在凌晨三点对着示波器骂娘。你去看任何一个芯片的参考手册,UART那一章永远是最早被翻烂的,但也是最容易被轻视的——很多人觉得"不就是收发几个字节嘛",结果项目里第一个卡住量产的问题往往就出在串口上。

这一期咱们就把UART串口从里到外扒一遍。不管你是刚拿到STM32开发板的新手,还是正在调试GD32F470VET6这种高端货的老鸟,或者是用FPGA实现UART发送ASCII字符串的硬核玩家,这篇内容都能让你找到能直接抄作业的东西。我会从协议本质讲起,把STM32、GD32、Linux、FPGA几个平台的UART实现串起来,重点聊那些手册上不会写、但实际调试中一定会遇到的坑——比如串口DMA为什么丢数据、CH340驱动装不上怎么办、Win7下怎么查串口被哪个程序占用、TTL UART通过光耦到底能传多远这些具体问题。

先给个结论:UART这东西,入门只要十分钟,精通需要十年。它的核心价值不在于"能通信",而在于它是整个嵌入式系统的调试命脉。你想想,板子跑起来第一件事是不是printf?固件升级是不是靠串口下载?设备现场出问题是不是先接串口看log?所以我说,UART是嵌入式工程师的第一课,也是最后一课——你永远在跟它打交道,永远能发现新的坑。

2. UART协议的本质:异步、全双工、起止式传输

2.1 两根线背后的时序逻辑

UART的全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。注意"异步"这两个字,这是理解一切UART问题的钥匙。所谓异步,就是收发双方没有共享时钟线,全靠事先约定好的波特率来对时。这就好比你跟朋友约好每天早上七点打电话,没有闹钟提醒,全靠各自的生物钟——只要有一个人的表快了或慢了,通话就会错乱。

具体到电气层面,UART空闲时线路上是高电平,起始位是一个波特率周期的低电平,接着是5到9个数据位(通常8位),可选的校验位,最后是1到2个停止位的高电平。接收方检测到下降沿就开始采样,采样点通常在位周期的中间位置,这样能最大程度避开边沿抖动。我见过太多人调试时波形看着"差不多",但就是收不到正确数据,十有八九是波特率误差累积导致的采样点偏移。

这里有个经验公式:波特率误差要控制在2%以内,最好在1%以内。比如你用8MHz晶振配9600波特率,分频系数算出来是52.08,取整52的话实际波特率是9615,误差0.16%,完全没问题。但如果用内部RC振荡器,温漂可能就有3%,这时候通信就会时好时坏。所以我的建议是:只要条件允许,UART通信一定要用外部晶振,别省那两个电容的钱。

2.2 全双工与半双工的接线差异

标准UART是全双工的,TX接对方的RX,RX接对方的TX,再加上GND,三根线就能双向通信。但实际项目中经常遇到单线半双工的场景,比如某些传感器或者RS485总线。这时候怎么和全双工设备连接?答案是加一个方向控制电路,或者用带方向自动切换的收发器。

我遇到过最坑的情况是:一个客户把两个全双工设备的TX接在一起、RX接在一起,然后问我为什么通信不了。这相当于两个人同时对着对方喊话,谁都听不清。正确的做法是交叉连接,或者用交叉线序。如果你不确定,拿万用表量一下,TX对GND的电压在空闲时应该是高电平(3.3V或5V),RX端同理。

2.3 校验位与停止位的实际选择

校验位这东西,理论上能检测单比特错误,但实际项目中我基本不用。为什么?因为现代通信环境里,干扰往往是突发性的多比特错误,奇偶校验根本无能为力。而且加了校验位,数据位就得从8位降到7位,对于传输二进制数据来说非常不方便。所以我的默认配置永远是:8数据位、无校验、1停止位,也就是常说的8N1。

停止位选1还是2?绝大多数情况选1就够了。选2的唯一场景是接收方处理速度特别慢,需要额外时间准备下一个字节。但这种情况现在很少见了,因为现代MCU的UART都有FIFO或者DMA,处理速度不是瓶颈。

3. STM32/GD32平台UART驱动的完整实现路径

3.1 从寄存器到HAL库:三种开发方式的取舍

在STM32或GD32上搞UART,你有三条路可以走:直接操作寄存器、用标准外设库、用HAL库。我这些年三种都用过,说说各自的适用场景。

直接操作寄存器适合对时序要求极其苛刻的场景,比如你要在某个精确的时刻发送数据,或者需要极致优化中断响应时间。但缺点是移植性差,换个芯片就得重写。标准外设库是ST早期的方案,现在新项目基本不用了,但很多老代码还在维护。HAL库是ST现在主推的,配合STM32CubeMX可以图形化配置,开发效率最高,但代码体积大、执行效率略低。

我的建议是:新项目直接用HAL库,别纠结那点效率损失。现在MCU的Flash和RAM都够大,开发效率比运行效率重要得多。但你要理解HAL库底层做了什么,否则出了问题根本无从下手。

以STM32F103串口打印为例,用CubeMX配置USART1,波特率115200,8N1,开启中断。生成的代码里,HAL_UART_Transmit()是阻塞发送,HAL_UART_Transmit_IT()是中断发送,HAL_UART_Transmit_DMA()是DMA发送。新手最容易犯的错误是在中断里调用阻塞发送函数,结果整个系统卡死。记住一个原则:中断里只做标记和搬运,不做等待。

3.2 串口DMA的正确打开方式

串口DMA是提升系统效率的利器,但也是丢数据的重灾区。我见过太多人配置了DMA发送,结果发现数据发不全,或者接收时丢包。根本原因通常有三个:DMA缓冲区被覆盖、DMA传输完成标志没清除、中断优先级配置不当。

先说发送。用DMA发送时,CPU把数据丢给DMA就返回了,DMA在后台慢慢发。但如果你在DMA还没发完的时候又调用了一次发送函数,就会覆盖缓冲区。正确的做法是维护一个发送队列,或者用HAL_UART_GetState()检查状态。更稳妥的方案是用HAL_UART_Transmit_DMA()配合传输完成回调,在回调里发下一个包。

再说接收。串口接收DMA最坑的地方是"不知道对方发了多少数据"。DMA是按固定长度搬运的,但串口数据是流式的。解决方案有两种:一是用空闲中断(IDLE Interrupt),当总线空闲一个字节时间后触发中断,这时候读取DMA剩余计数就能知道收到了多少数据;二是用DMA的循环模式配合环形缓冲区。我个人推荐空闲中断方案,代码简洁,实时性好。

// STM32串口DMA接收+空闲中断的核心代码 void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); uint16_t recv_len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 处理recv_len个字节的数据 process_data(rx_buffer, recv_len); HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE); } }

这段代码我用了好几年,在各种STM32和GD32芯片上都跑得很稳。注意__HAL_DMA_GET_COUNTER返回的是剩余未传输的字节数,用总长度减去它就是已接收的长度。

3.3 GD32F470VET6的串口配置差异

GD32F470VET6是兆易创新的高性能MCU,主频能到240MHz,串口资源也很丰富。但它的库函数和STM32的HAL库有差异,不能直接照搬。比如GD32的固件库叫"GD32F4xx_Firmware_Library",函数命名风格是usart_baudrate_set()这种,而不是HAL的HAL_UART_Init()。

我在GD32F470上踩过的一个坑是:它的串口时钟使能和STM32不一样。STM32的USART1挂在APB2上,GD32的USART0(对应STM32的USART1)也是APB2,但有些串口挂在APB1上,配置时容易搞混。还有中断向量表的名字也不同,STM32叫USART1_IRQHandler,GD32叫USART0_IRQHandler。移植代码的时候这些细节必须一个一个核对。

另外GD32F470支持更高的波特率,理论上能到十几兆。但实际用的时候,超过1M波特率就要考虑信号完整性问题了,线太长或者没有阻抗匹配,误码率会飙升。

4. Linux与Android平台的串口编程实战

4.1 Linux串口设备节点与权限管理

在Linux下搞串口,第一步是找到设备节点。常见的命名有/dev/ttyS0(原生串口)、/dev/ttyUSB0(USB转串口)、/dev/ttyACM0(CDC类设备)。用ls /dev/tty*能看到所有串口设备,用dmesg | grep tty能看到内核识别串口时的日志。

Ubuntu下查看串口设备的命令我常用这几个:

# 查看所有串口设备 ls -l /dev/ttyS* /dev/ttyUSB* /dev/ttyACM* # 查看串口驱动信息 dmesg | grep -i tty # 查看串口参数 stty -F /dev/ttyUSB0 -a # 设置串口参数 stty -F /dev/ttyUSB0 115200 cs8 -cstopb -parenb

权限问题是最常见的坑。普通用户默认没有串口设备的读写权限,要么用sudo,要么把用户加到dialout组里:sudo usermod -aG dialout $USER,然后重新登录生效。我见过有人用chmod 777解决,这在开发阶段可以,但生产环境绝对不行,安全风险太大。

4.2 Linux串口接收数据丢失的排查思路

"Linux从串口接收数据丢失"这个问题在热词里出现了,说明很多人遇到过。我总结的原因通常有这几类:

第一类是缓冲区溢出。Linux的串口驱动有内核缓冲区,默认大小可能只有4KB。如果应用层读取不及时,数据就会被覆盖。解决办法是用termios结构体设置VMIN和VTIME,或者用select/poll/epoll做多路复用,确保有数据就立刻读。

第二类是波特率不匹配。Linux的波特率设置有个坑:非标准波特率需要用termios2结构体和TCGETS2/TCSETS2ioctl,标准的cfsetispeed只支持有限几种波特率。如果你要用921600这种,就得用特殊方法。

第三类是USB转串口芯片的延迟。FT232R、CH340这些芯片内部有缓冲区,默认可能有十几毫秒的延迟。可以通过修改驱动参数或者用setserial命令调整。FTDI芯片可以用ftdi_sio模块的latency_timer参数,设成1ms能显著降低延迟。

4.3 Android板子做串口通讯为什么这么麻烦

Android本质上也是Linux,但它的权限管理和标准Linux差别很大。普通Android应用没有权限直接访问/dev/ttyS*,除非设备已经root,或者应用有系统签名。这就是为什么很多人觉得"Android板子做串口通讯特别麻烦"。

常见的解决方案有三种:一是用USB转串口,通过Android的USB Host API访问,但需要应用申请USB权限;二是让设备厂商在系统层做好串口服务,应用通过Socket或Binder调用;三是root设备后直接操作设备节点。第一种最规范但兼容性受芯片影响,第二种最稳定但依赖厂商配合,第三种最灵活但只适合内部项目。

我在全志V3S这类嵌入式Linux板子上做串口通信时,通常会在设备树里把串口配置好,然后在应用层用标准的open/read/write操作。全志的串口驱动比较成熟,但要注意它的UART时钟源配置,配错了波特率会偏差很大。

5. USB转串口芯片的驱动与调试

5.1 CH340、FT232R、FT231X的驱动安装差异

USB转串口是PC端调试嵌入式的标配,但驱动安装经常让人抓狂。CH340是国内用得最多的,便宜好用,但Win7下需要手动装驱动,Win10之后系统自带。FT232R和FT231X是FTDI的芯片,稳定性好但价格贵,驱动需要从官网下载。

Win7下装CH340驱动有个经典问题:装完之后设备管理器里显示黄色感叹号,提示"该设备的驱动程序未被安装"。这通常是因为驱动签名问题。解决办法是开机时按F8,选择"禁用驱动程序签名强制",然后重新安装。或者用驱动精灵这类工具自动匹配,但我不推荐,因为可能装到带广告的版本。

FTDI芯片的驱动安装相对省心,但要注意FT231X和FT232R的驱动是同一个,装最新的VCP驱动就行。如果装完发现串口号一直在变,可以在设备管理器里手动指定COM端口号,避免每次插拔都换号。

5.2 Win7下查看串口被哪个程序占用

这个问题在热词里出现了,说明是很多人的痛点。Windows不像Linux有lsof命令,查看串口占用比较麻烦。我常用的方法有这几个:

方法一:用Process Explorer。这是微软官方Sysinternals工具集里的,打开后按Ctrl+F搜索"COM",能找到哪个进程打开了串口句柄。

方法二:用PowerShell命令。Get-Process | Where-Object {$_.Modules.FileName -like "*serial*"}能列出加载了串口相关模块的进程,但不一定准确。

方法三:用串口调试助手的"强制打开"功能。有些助手(比如SSCOM)支持强制占用串口,如果它能打开而别的程序打不开,说明串口被占用了。

最彻底的办法是重启电脑,但这不是解决问题的态度。我建议在开发阶段就养成好习惯:用完串口及时关闭,别让调试助手在后台挂着。

5.3 串口烧录失败的常见原因

"串口烧写失败"和"使用STM32CubeProgrammer通过串口给单片机下载程序"这两个热词放在一起看,问题就很明确了。用串口给STM32下载程序,需要芯片进入Bootloader模式,通常是把BOOT0拉高、BOOT1拉低,然后复位。

烧录失败的常见原因:一是BOOT引脚状态不对,用万用表量一下,BOOT0必须是高电平;二是串口线序不对,TX和RX要交叉;三是波特率太高,STM32的系统Bootloader对波特率有要求,通常用115200比较稳;四是芯片的读保护没解除,需要用STM32CubeProgrammer先解除保护。

CH340X这个芯片支持一键下载电路,能自动控制BOOT0和复位,省去了手动跳线的麻烦。但电路设计要注意,CH340X的DTR和RTS引脚要接到MCU的BOOT0和NRST上,而且逻辑电平要匹配。

6. FPGA实现UART:从Verilog代码到实际波形

6.1 UART发送模块的Verilog实现要点

用FPGA实现UART发送ASCII字符串,核心是一个状态机加一个波特率计数器。状态机负责把并行数据转成串行位流,计数器负责控制每个位的持续时间。

// UART发送模块的核心状态机 module uart_tx( input clk, input rst_n, input tx_start, input [7:0] tx_data, output reg tx, output reg tx_done ); parameter BAUD_CNT = 868; // 50MHz / 115200 ≈ 434, 这里用2倍频采样 reg [1:0] state; reg [15:0] cnt; reg [3:0] bit_idx; reg [9:0] shift_reg; always @(posedge clk or negedge rst_n) begin if(!rst_n) begin state <= 0; tx <= 1; tx_done <= 0; end else begin case(state) 0: if(tx_start) begin shift_reg <= {1'b1, tx_data, 1'b0}; // 停止位+数据+起始位 state <= 1; cnt <= 0; bit_idx <= 0; end 1: begin if(cnt == BAUD_CNT-1) begin cnt <= 0; tx <= shift_reg[0]; shift_reg <= {1'b1, shift_reg[9:1]}; if(bit_idx == 9) begin state <= 2; end else begin bit_idx <= bit_idx + 1; end end else begin cnt <= cnt + 1; end end 2: begin tx_done <= 1; state <= 0; end endcase end end endmodule

这段代码的关键点是:起始位是低电平,停止位是高电平,数据位从LSB开始发。波特率计数器的值要根据系统时钟和波特率算出来,比如50MHz时钟、115200波特率,分频系数是50_000_000/115200≈434。但为了采样准确,通常用2倍或4倍过采样。

6.2 FPGA串口的时序约束与实测波形

FPGA做UART,时序约束很重要。如果时钟频率高、走线长,不加约束可能综合出来的电路时序不满足。我通常会在XDC或SDC文件里加这几条:

# 创建时钟约束 create_clock -period 20.000 -name sys_clk [get_ports clk] # 输入输出延迟约束 set_input_delay -clock sys_clk 2.000 [get_ports rx] set_output_delay -clock sys_clk 2.000 [get_ports tx]

实测波形的时候,重点看起始位的下降沿是否干净、每个位的宽度是否一致、停止位是否回到高电平。如果发现波形有振铃或者过冲,可能是驱动能力太强或者线太长,可以在输出端串一个22欧姆到100欧姆的电阻。

7. 那些手册上不会写的UART实战经验

7.1 TTL UART通过光耦能传多远

这个问题很具体,我直接给结论:取决于光耦的速度和传输波特率。普通PC817光耦的上升下降时间在微秒级,传9600波特率没问题,传115200就勉强了。高速光耦比如6N137,能支持到10M波特率,传输距离可以到几十米。

但光耦传输有个致命问题:它是有方向性的,而且需要限流电阻。设计电路时,发光二极管侧的电流一般取5到10mA,光敏三极管侧需要上拉电阻。传输距离还受线缆电容影响,普通杜邦线每米大概50pF,加上光耦的输入电容,信号边沿会变缓。我的经验是:9600波特率下,PC817能传5米左右;115200波特率下,最好别超过1米,否则误码率会明显上升。

7.2 串口调试助手的选择与使用技巧

SSCOM是我用得最多的串口调试助手,功能全、稳定、免费。但有几个设置技巧很多人不知道:一是"时间戳"功能,勾选后每条接收数据前面会加上时间,方便分析时序;二是"自动换行"和"HEX显示"的配合,调试二进制协议时特别有用;三是"多条发送"功能,可以预设常用命令,一键发送。

另一个常用的是SecureCRT,适合Linux开发,支持SSH和串口。它的优势是会话管理方便,可以保存多套配置。但它是收费软件,免费替代品可以用PuTTY或者MobaXterm。

7.3 串口通信协议的设计建议

如果你要设计一个基于串口的通信协议,我的建议是:帧头+长度+命令字+数据+校验+帧尾。帧头用两个字节的固定值,比如0xAA 0x55,减少误判。长度字段要明确是数据长度还是总长度,避免歧义。校验用CRC16比累加和可靠得多。

还有一个经验:协议里一定要有超时重传机制。串口通信受干扰的概率不低,没有重传的话,丢一个包就可能导致整个系统卡死。重传次数建议3次,超时时间根据波特率和数据长度算,一般100ms到500ms。

8. 串口问题排查的完整链路

8.1 从物理层到应用层的逐级排查

串口出问题,最忌讳的就是瞎猜。我总结了一套排查链路,按顺序来,基本能定位90%的问题。

第一步,查物理连接。TX和RX有没有交叉?GND有没有接?电压电平匹配吗?3.3V的MCU接5V的USB转串口,不加电平转换可能烧芯片。用万用表量TX对GND的电压,空闲时应该是高电平。

第二步,查波特率。双方波特率必须一致,误差控制在2%以内。用示波器量一个位的宽度,比如115200波特率下,一个位是8.68微秒。如果量出来偏差很大,检查时钟源配置。

第三步,查数据格式。数据位、停止位、校验位必须完全一致。8N1对8N1,不能一个8N1一个8E1。

第四步,查流控。如果双方都开了硬件流控(RTS/CTS),但线没接,数据就发不出去。软件流控(XON/XOFF)也一样,一方开了另一方没开,就会死锁。

第五步,查软件配置。中断优先级、DMA配置、缓冲区大小,这些都会影响通信。特别是中断优先级,如果串口中断被高优先级中断长时间阻塞,数据就会丢。

8.2 几个经典故障的复现与修复

故障一:串口能发不能收。最常见的原因是RX引脚配置错了,比如配成了输出模式,或者复用功能没使能。用示波器量RX引脚,如果有波形但MCU收不到,就是配置问题。

故障二:数据偶尔错位。这通常是波特率误差累积导致的。把双方波特率误差算出来,如果超过2%,就得换晶振或者调整分频系数。

故障三:DMA接收丢包。检查DMA缓冲区的对齐方式,有些芯片要求4字节对齐。还要检查DMA中断优先级,如果太低,可能被其他中断打断导致数据覆盖。

故障四:USB转串口识别不到。先换USB线,再换USB口,然后查驱动。CH340在Win7下要手动装驱动,FT232R要装VCP驱动。如果设备管理器里能看到但打不开,可能是被其他程序占用了。

9. 跨平台串口编程的封装思路

9.1 一套代码适配Windows和Linux

如果你写的串口程序要同时跑在Windows和Linux上,建议做一层抽象。核心接口就四个:打开、关闭、读、写。Windows用CreateFile/ReadFile/WriteFile,Linux用open/read/write。用条件编译区分:

#ifdef _WIN32 HANDLE fd = CreateFile(port, GENERIC_READ|GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); #else int fd = open(port, O_RDWR | O_NOCTTY | O_NDELAY); #endif

波特率设置差异更大。Windows用DCB结构体,Linux用termios结构体。建议把配置参数封装成一个结构体,两边各自实现。

9.2 串口封装C库的设计要点

我写过一个跨平台的串口库,核心设计是:用环形缓冲区做接收缓冲,用互斥锁保护读写操作,用条件变量实现阻塞读。这样上层应用不用关心底层是Windows还是Linux,也不用担心多线程竞争。

环形缓冲区的关键是读写指针的原子操作。在单生产者单消费者场景下,可以不用锁,用内存屏障保证顺序就行。但多生产者或多消费者就必须加锁。

超时处理也很重要。串口读操作不能无限阻塞,否则程序没法正常退出。我通常设置100ms超时,超时后返回0,上层根据返回值决定继续等还是退出。

10. 我个人在UART调试中的几个习惯

干了这么多年,我养成了几个习惯,分享出来可能对你有用。

第一个习惯:每个项目的第一行代码永远是串口打印。不管什么芯片,先把串口调通,能printf了再干别的。这就像盖房子先通水电,后面所有调试都靠它。

第二个习惯:串口日志分级。用宏定义区分DEBUG、INFO、WARN、ERROR,发布版本关掉DEBUG,只留ERROR。这样既不影响性能,又能保留关键信息。

第三个习惯:关键数据用HEX打印。ASCII打印方便看字符串,但二进制数据必须用HEX,否则乱码根本没法分析。SSCOM的HEX显示功能我几乎每个项目都用。

第四个习惯:保留一个"紧急串口"。产品定型后,我会留一个隐藏的串口命令,用于现场恢复出厂设置或者强制升级。这个命令不写在文档里,只有内部人员知道。

第五个习惯:示波器常备。串口问题最终都要落到波形上,一个几百块的逻辑分析仪或者入门示波器,能省下大量猜测的时间。看波形的时候重点看起始位、停止位和位宽,这三个对了,基本就没大问题。

UART这东西,说简单是真简单,两根线就能通;说复杂也是真复杂,每个平台都有各自的坑。但正是因为它简单,才成了嵌入式系统的基石。你把UART吃透了,再看I2C、SPI、CAN这些协议,会发现很多思路是相通的。所以别嫌它基础,基础的东西往往最考验功力。

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

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

立即咨询