
入行做嵌入式第一件事儿不是点灯是把串口调通。UART串口作为最基础的通信协议几乎所有MCU、传感器模组、4G模块、蓝牙模块都带这个接口。哪怕你后面做USB、做以太网调试阶段大概率还是要靠串口输出日志。这篇“通信协议详解一”就把UART从头讲透协议层怎么定义的、一帧数据长什么样、从硬件到代码怎么落地再附上我这些年联调踩过的坑。做单片机、物联网设备、上位机开发的朋友这篇对你有用。1. 先把UART这件事说透它到底是个啥1.1 串口、UART、RS-232这一堆名字账得算清楚刚入行那阵子我经常被“串口”、“UART”、“RS-232”这几个词搞混后来发现其实它们是三个层面的事。UARTUniversal Asynchronous Receiver/Transmitter是通用异步收发器。它既是协议也是芯片内部的一个硬件外设模块。说它是协议因为它规定了数据怎么按位发出去说它是硬件模块是因为MCU里专门有一块电路负责这件事你只需要往寄存器里写数据它自己就把电平翻转、移位这些脏活干了。串口Serial Port是一个更口语化的泛称指所有串行通信接口。大家平时说“把这个串口接出来”、“串口调试助手上能看到数据”指的就是UART串口。RS-232是另一个层面的东西它是EIA电子工业联盟定义的电气标准管的是电压多少伏、用哪种接头。UART决定了数据怎么排布RS-232决定了信号怎么在线缆上传输一个是“格式”一个是“物理通道”。实际开发中MCU引脚直接输出的是TTL电平的UART信号电平范围是0到3.3V或5V。而老式电脑的DB9串口走的是RS-232电平正负电压表示高低。两边直接怼上去肯定不行所以要靠MAX232、CH340、FT232这类电平转换芯片桥接。现在大家用的USB转串口小模块本质就是把USB信号转成TTL电平的UART方便电脑和板子通信。提示买USB转串口模块时注意看输出电压是3.3V还是5V最好选带跳线切换的。电平选错了轻则收不到数据重则烧芯片。1.2 三根线打天下TX、RX、GND怎么接UART通信最少只需要三根线TX发送、RX接收、GND地。连接规则一句话TX接对端的RXRX接对端的TXGND对接。这里很多人第一次会绕晕其实想想就通了A要向B发数据A的TX必须连B的RX这样B才能“听见”A在说什么。同理B的TX连A的RX。所以两个设备直接连的时候TX和RX是交叉的。GND为什么必须连因为UART是单端信号接收端判断电平高低是拿信号线和GND比出来的。如果不共地A的3.3V高电平在B眼里可能是2.8V可能判成不确定状态通信直接乱套。板子之间距离近时一根杜邦线就能解决距离远了还得考虑信号完整性和隔离问题。由于TX和RX互相独立UART天生就是全双工的发数据和收数据可以同时进行。这一点比后面要讲的I2C、SPI更灵活。实际项目里UART还经常做半双工应用比如RS-485总线那就需要额外控制收发方向了。1.3 TTL电平、RS-232电平、RS-485别把电压搞混我见过不少朋友把设备连上却没反应最后发现是电平标准不匹配。TTL电平下的UART逻辑12.4V到VCC通常3.3V或5V逻辑00V到0.4V注意TTL是正逻辑高电平表示1RS-232电平下逻辑1-15V到-3V逻辑03V到15V看出来了吗RS-232是反着的而且电压范围大得多所以能传更远的距离十五米左右代价是电路复杂、需要专门的收发芯片。单片机开发板上引出的串口绝大部分是TTL电平接模块、接传感器时用的就是TTL UART。接电脑的DB9串口、接老式工控机时才需要RS-232电平转换。还有一种是RS-485用差分信号传输A、B两根线抗干扰能力强能传上千米常出现在工业现场总线里。RS-485本质上也可以看作UART的一个“物理层变种”数据格式照旧只是由两根差分线代替了单端TX/RX。前面提到的热词里就有CAN、EtherCAT这些工业总线它们跟UART不冲突各有各的用武之地。2. 数据格式拆解一个字节是怎么飞出去的2.1 一帧数据的长相起始位、数据位、校验位、停止位UART是异步通信收发双方没有一个独立的时钟线。那接收端怎么知道对方什么时候开始发数据靠的就是帧结构里的起始位。空闲状态下TX线保持高电平。发送端要发数据时先拉低一个位时间这个下降沿就是给接收方的“注意我开始发了”信号叫起始位。接收方检测到起始位后按约定的波特率每隔一个位时间采样一次依次读出数据位。标准的一帧UART数据由这几部分组成帧的部分位长度电平状态作用起始位1位低电平告知接收方开始传输数据位5~9位常用8位按数据内容变化实际有效载荷校验位0或1位视校验方式而定简单的错误检测停止位0.5~2位常用1位高电平告知接收方一帧结束数据位低位先发也就是LSB First。比如发送十六进制0x55二进制01010101线上先出现的是最低位1然后依次是0、1、0、1、0、1、0。很多初学者拿逻辑分析仪抓波形一看数据跟心里想的不一样多半是没注意LSB先行这个规矩。常见的参数组合是“8-N-1”意思是8位数据、无校验、1位停止位。此外还有8-E-1偶校验、8-O-1奇校验、7-E-17位数据偶校验等。如果没有特别原因用8-N-1就行。2.2 波特率是怎么算出来的误差多少会翻车波特率定义为一秒钟传输的码元数单位是Baud波特。在UART里一个码元就是一个位所以波特率数值上等于比特率bps。常见的有9600、19200、38400、57600、115200。115200是很多模块默认值速度快且大多数时钟频率下计算误差小。波特率直接决定每一位的“时间宽度”。比如9600波特率下一个位的时间是位时间 1 / 9600 ≈ 104.17微秒双方约定好波特率后接收方就在起始位之后的每个位时间中点采样一次这样能最大程度避开信号翻转边沿的抖动区间。但有个现实问题MCU的时钟频率不是随便就能整除目标波特率的。以STM32F103C8T6为例USART1挂在APB2总线上时钟72MHz。波特率寄存器BRR的值这样算根据参考手册USARTDIV 时钟频率 / (16 × 目标波特率)代入72MHz和115200USARTDIV 72000000 / (16 × 115200) 39.0625写入BRR时要分成整数部分和小数部分。整数部分390x27小数部分用0.0625 × 16 1。所以BRR 0x271。此时实际波特率实际波特率 72000000 / (16 × 39.0625) 115200误差为0%完美的整除。但很多频率组合没那么幸运比如8MHz的时钟跑9600波特率USARTDIV 8000000 / (16 × 9600) 52.08左右舍入后会有千分之几的误差。只要误差在±2%以内通常都没问题。注意比波特率计算更坑的是时钟源配置错误。比如STM32F103内部RC振荡器HSI精度约±2%如果软件里没切换外部晶振就跑串口波特率会整体偏移。单片机刚上电用HSI跑时电脑端可能收到一堆乱码一旦换成HSE就恢复正常别问我是怎么知道的。2.3 校验位是防呆不防傻奇偶校验到底有没有用奇偶校验的原理很简单发送方统计数据位里1的个数如果是偶校验就设置校验位让“数据位校验位”中1的总数为偶数奇校验则让总数为奇数。接收方收到后同样统计如果对不上就说明这一帧有误。但这里必须说句实话奇偶校验只能检测奇数个位的翻转偶数个位翻转时校验结果不变检查不出来。而且它不能定位是哪一位错了没有纠错能力。所以别把它当成网络安全协议里的CRC。实际项目里TTL短距离通信板内、板间几十厘米噪声干扰小一般直接不开校验参数就是8-N-1。长线传输、工业环境有条件上CRC更好比如Modbus RTU用CRC16。如果协议强制要求带校验位那就按协议来比如很多老式仪器会用到7-E-1。3. 从原理到落地一套能直接用的收发设计3.1 硬件侧要留意的几个点设计阶段就把下面几个事确认好能省掉后面大量联调时间。第一MCU的UART外设数量够不够用。有些项目要同时挂4G模块、GPS模块、蓝牙模块每个模块各占一路UART选型时就要数清楚。MCU引脚的复用功能AF也要查仔细比如STM32的USART1可以用PA9/PA10也可以用PB6/PB7但两者不能同时启用。第二串口线上的上拉电阻。UART空闲电平是高电平如果MCU引脚内部有上拉通常够用。但对外接模块的板子建议外部再加4.7kΩ到10kΩ上拉增强抗干扰能力。如果走RS-485A/B线还要加终端匹配电阻通常120Ω那是另一个话题了。第三USB转串口模块的选型。日常调试用CH340就够了便宜大碗Windows驱动也好装。FT232更稳定一些适合固件下载失败率高或者需要长期高负载传输的场景。热词里有人搜FT231X、FT232R驱动装不上多半是驱动版本和系统不匹配去原厂官网下载最新驱动基本能解决。第四串口保护。如果板子要引出到外部比如接工控设备、接户外传感器最好加TVS管或ESD保护器件。我有一块板子就是没加保护插拔几次串口线之后UART引脚就挂了。3.2 STM32上跑通一轮收发HAL库实操现在用STM32CubeMX生成工程的打法很主流我以STM32F103C8T6加HAL库为例讲一下UART初始化和收发。使用CubeMX时需要先配置RCC、SYS的Debug Serial Wire给USART1选择异步模式Asynchronous设置波特率115200、8位数据、无校验、1位停止位。生成代码后核心的初始化函数是这样static void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; 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; if (HAL_UART_Init(huart1) ! HAL_OK) { Error_Handler(); } }发送数据最简单的方式是阻塞轮询HAL_UART_Transmit(huart1, (uint8_t *)Hello UART\r\n, 13, 1000);最后一个参数是超时时间单位毫秒。阻塞模式下函数会一直等发送完成CPU被占着尤其在大批量日志输出时会有性能问题。接收数据如果也用阻塞模式在RTOS里会把任务卡死所以实战中更常用中断方式。每次收一个字节进一次回调uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { /* 把数据塞入环形缓冲区 */ ring_buf_put(rx_buf, rx_byte); /* 重新开启下一次接收 */ HAL_UART_Receive_IT(huart1, rx_byte, 1); } }注意HAL_UART_Receive_IT每次只能触发一次接收收到一个字节就会进回调所以必须在回调里再次调用否则收了一个字节后就停了。有了环形缓冲区应用层就能随时取数据不用担心中断里面处理太多事情影响实时性。环形缓冲区实现不复杂我用的是这个结构体typedef struct { uint8_t buffer[512]; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t;head是写入位置tail是读取位置当两者相等时缓冲区为空。读的时候检查是否为空写的时候检查是否满了满了就把旧数据丢掉或者置一个溢出标志。3.3 阻塞、中断、DMA到底怎么选这是UART设计里最实际的一个问题。我把三种模式放在一起对比模式CPU占用实时性适合场景阻塞轮询高差初始化日志、简单指令应答中断收发低较好一般业务交互、不定长协议DMA收发极低好大数据量传输、高波特率日志阻塞模式在裸机项目里最省事但遇到需要同时处理多个任务时就不行了。中断模式是大多数项目的默认选择配合环形缓冲区足够应付日常通信。DMA模式适合高速率大数据比如一次发几KB的数据用中断模式CPU要频繁进出中断效率低而DMA可以让外设直接读写内存CPU完全不用管。DMA还有一招很实用DMA加串口空闲中断实现不定长接收。很多模块的数据帧长度不固定比如AT指令的响应有长有短。做法是开启UART的DMA接收和IDLE中断当总线空闲时触发一次空闲中断此时从DMA缓冲区里读走当前收到的全部数据。HAL库里可以直接用HAL_UARTEx_ReceiveToIdle_DMA省去自己写寄存器。我在一个4G模块透传项目里用这套方案代码逻辑大概是HAL_UARTEx_ReceiveToIdle_DMA(huart1, dma_buf, sizeof(dma_buf)); /* 在空闲中断回调里处理收到的不定长数据 */这个用法的关键是从DMA缓冲区判断实际收到多少字节不要在空闲中断里做耗时操作只做数据搬移和标志置位。4. 实战中一定会遇到的坑和排查套路4.1 乱码先别急按这个顺序查串口调试最头大的就是打开串口助手收到一堆“烫烫烫”或者乱码。我的排查顺序始终是固定的。第一步查波特率。双方是不是都配置成115200了。有的模块默认是9600有的默认115200接上之前先看数据手册。第二步查时钟源。板子用的是外部晶振还是内部RC振荡器外部晶振频率跟配置是否一致有些STM32最小系统板板载了8MHz晶振但CubeMX里如果配置成其他频率UART波特率也会跟着偏。第三步查电平匹配。是不是把TTL设备接到了RS-232设备上中间少了转换芯片或者USB转串口模块输出是5V直接接到了3.3V的MCU引脚上长期看有风险甚至烧坏引脚。第四步查GND。我之前接一块传感器板怎么调都是乱码最后发现是只接了两根信号线忘了共地。两个设备电位基准都不同数据自然对不上。如果按这个顺序查完还乱码掏出逻辑分析仪抓一下波形对比实际位宽和理论值。这一招能直接定位是发送端的问题还是接收端的问题。4.2 丢数据、卡死多半是缓冲区的问题数据丢失和程序卡在UART中断里是两块最让人头疼的问题。先说丢数据。中断接收模式下如果中断里处理时间太长下一个字节到达时就没法及时响应硬件接收寄存器会被覆盖。解决办法就是前面提到的环形缓冲区中断里只做最简单的字节入队解析和业务处理放到主循环或RTOS任务里。再说Linux下的情况。热词里就有“linux从串口接收数据丢失”这个坑我也踩过。Linux下使用串口时如果不做配置默认的termios参数下串口驱动有自己的一套行规则比如会对输入做回显、按行缓冲、特殊字符处理。实际开发中需要设置原始模式raw mode关掉ICANON、ECHO、ISIG这些选项。关键的两个参数newtio.c_cc[VMIN] 1; /* 最少读取1个字节才返回 */ newtio.c_cc[VTIME] 0; /* 不设超时 */VMIN设置一次read()操作至少返回所需的字节数VTIME设置等待时间。如果VMIN为0read会立即返回当前缓冲区里的数据可能造成粘包如果只设VTIME不设VMIN在高波特率下没等数据来齐就超时就会出现丢数据的假象。把这些配置对了Linux下串口收发就稳定了。嵌入式端还有一个常见的卡死原因中断里调用了printf或其他库函数。printf本身耗时且可能不可重入一旦在中断上下文里调用轻则数据错乱重则直接进HardFault。我的习惯是所有日志输出都通过一个专用的日志发送函数内部用DMA异步发送never在中断里直接printf。4.3 驱动装不上、烧写失败这些破事热词里很多人搜CH340串口驱动、FT232R驱动下载还有串口烧写失败。驱动问题有个通用解法不要用驱动精灵这种第三方工具直接去芯片官网下载对应系统的驱动Windows和macOS的安装包不一样。CH340芯片的驱动在沁恒官网FT232在FTDI官网。装好驱动之后设备管理器里能看到COM口号。如果插上没反应依次检查USB线是不是只能充电不能传数据是不是用了USB集线器导致的供电不足直接插电脑USB口试试电脑上是否同时装了几个USB转串口驱动导致设备识别到了错误驱动上烧写失败除了驱动问题还有一个经常被忽略的点BOOT0引脚的电平状态。STM32上电时BOOT0为低电平时从Flash启动正常跑程序如果你想用串口烧录需要把BOOT0拉高再复位烧完再拉回来。很多“串口烧写失败”其实是忘了这个步骤。还有一点烧写软件和串口助手不要同时占用同一个串口。哪怕只是打开串口助手挂着不操作也会独占串口导致烧写工具打不开。4.4 一个能救命的小工具逻辑分析仪排查UART问题时我最推荐的工具不是示波器而是USB逻辑分析仪。便宜的几十块钱采样率24MHz够用能看到完整的帧结构。抓波形时怎么定位问题先看起始位下降沿是否存在再看每个位的时间宽度是否符合波特率要求。比如9600波特率下一个位应该是104微秒左右如果你抓到的位宽是130微秒说明发送端的时钟偏了问题在单片机时钟配置上。这种方式比看十六进制数据直观多了能一眼分清是“数据错”还是“时序错”。写在后面做嵌入式这些年UART是我打交道最多的接口也是让我栽过最多跟头的接口。最初的一块板子就是因为晶振虚焊导致串口一直乱码查了整整两天才找到原因。后来我学乖了凡是串口问题一定先抓波形而不是盲目改波特率。一个小经验分享给各位新板子第一次上电时先用串口发一个0x55或者0xAA这样的固定字节逻辑分析仪抓一下波形确认位宽和帧格式都对再跑正式的业务代码。这就像打地基地基稳了后面再复杂的协议栈都不慌。UART理解透了后面再聊I2C、SPI会顺畅很多因为很多底层思维是相通的比如时钟极性和相位、从机地址、总线仲裁这些概念都是对比着学。下一篇通信协议详解我会结合I2C和实际项目来讲到时候再聊。