4、串口通信与协议:UART异步通信原理、printf重定向与串口调试、Modbus RTU协议实现、自定义通信协议设计

串口通信,说白了就是嵌入式系统里最基础、最常用的「对话方式」。我做了这么多年嵌入式,几乎每个项目都离不开它。无论是调试打印、传感器数据采集,还是与上位机通信,串口都是那个最可靠的「老伙计」。

这一章,咱们就把它彻底吃透。从最底层的UART原理,到实用的printf重定向,再到工业界标准的Modbus RTU协议,最后聊聊怎么设计一套自己的通信协议。嗯,内容不少,但都是硬货。

4.1 UART异步通信原理

UART,全称是Universal Asynchronous Receiver/Transmitter。注意「异步」这两个字——它没有独立的时钟线,全靠双方约定好的波特率来同步。

为什么会这样?因为串口线只有两根:TX(发送)和RX(接收)。没有时钟线,那就得靠「约定」来对齐节奏。就像两个人跳舞,没有指挥,那就得心里默念节拍。

数据帧格式,我习惯把它拆成四部分来看:

  • 起始位:1位,逻辑0。告诉接收方「我要开始发了」。
  • 数据位:通常5~8位,最常见的是8位。低位在前,高位在后。
  • 校验位:可选,奇校验或偶校验。用于简单的错误检测。
  • 停止位:1位或2位,逻辑1。告诉接收方「这一帧结束了」。

举个例子,发送一个字节0x55(二进制01010101),波特率9600,8N1(8数据位、无校验、1停止位)。波形会是这样:

起始位(0) + 1 + 0 + 1 + 0 + 1 + 0 + 1 + 0 + 停止位(1)

你想想看,0x55的二进制正好是01010101,交替变化。这在示波器上看是最漂亮的波形,也是我调试串口时最爱发的测试数据。

个人经验:调试串口时,如果数据全是乱码,90%是波特率没对上。我曾经在一个项目里排查了整整半天,最后发现是晶振频率偏差导致波特率误差超过了2%。记住,波特率误差要控制在±2%以内,否则长数据帧就会出错。

4.2 printf重定向与串口调试

做嵌入式开发,printf就是我们的「眼睛」。但MCU上可没有标准输出,怎么办?重定向!

说白了,就是把printf底层调用的那个字符输出函数,改成我们自己的串口发送函数。以STM32的HAL库为例,重定向其实就两步:

// 第一步:包含头文件
#include <stdio.h>

// 第二步:实现fputc函数
int fputc(int ch, FILE *f)
{
    // 等待发送完成
    while(!(USART1->SR & USART_SR_TXE));
    // 发送一个字节
    USART1->DR = (uint8_t)ch;
    return ch;
}

搞定之后,你就可以在代码里随意写printf了。但要注意,printf是有代价的——它很慢。尤其是浮点数打印,会占用大量CPU时间。

避坑指南:我曾经在一个实时性要求很高的电机控制项目里,在中断服务函数中用了printf。结果电机一启动就卡死。为什么?因为printf是阻塞的,它会一直等串口发送完才返回。在中断里用阻塞函数,等于自杀。记住:中断里不要用printf,用标志位或环形缓冲区。

我个人的习惯是,调试阶段用printf,发布版本全部注释掉。或者用宏开关控制:

#define DEBUG_ENABLE 1

#if DEBUG_ENABLE
    #define DEBUG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__)
#else
    #define DEBUG_PRINT(fmt, ...)
#endif

这样,发布时只需要把DEBUG_ENABLE改成0,所有调试打印就消失了,不影响性能。

4.3 Modbus RTU协议实现

Modbus RTU,工业自动化领域的事实标准。我做过不下十个Modbus项目,从温控器到变频器,从PLC到智能仪表,几乎都在用。

它的帧结构很简单:

字段长度说明
地址码1字节从机地址,1~247
功能码1字节如03读保持寄存器,06写单个寄存器
数据区N字节具体数据内容
CRC校验2字节低字节在前,高字节在后

这里最容易被忽略的是CRC校验。Modbus RTU用的是CRC-16,多项式0x8005。我建议直接查表法,速度快,代码也简洁:

uint16_t Modbus_CRC16(uint8_t *buf, uint16_t len)
{
    uint16_t crc = 0xFFFF;
    for(uint16_t i = 0; i < len; i++)
    {
        crc ^= buf[i];
        for(uint8_t j = 0; j < 8; j++)
        {
            if(crc & 0x0001)
                crc = (crc >> 1) ^ 0xA001;
            else
                crc >>= 1;
        }
    }
    return crc;
}

实现Modbus从机时,核心就是一个状态机。我习惯用以下状态:

  • IDLE:空闲状态,等待起始字节
  • RECV:接收数据中
  • CHECK:校验CRC
  • PROCESS:处理命令
  • SEND:发送响应

关键点:Modbus RTU的帧间隔是3.5个字符时间。如果两个字节之间的间隔超过这个时间,接收方就认为一帧结束了。这个时间怎么算?以9600波特率为例,一个字符约1ms,3.5个字符就是3.5ms。定时器要精确到毫秒级。

4.4 自定义通信协议设计

有时候Modbus太重量级,或者功能不匹配,那就得自己设计协议。我设计过不下五种自定义协议,总结下来,核心就四个字:可靠、简洁

一个典型的自定义协议帧结构:

帧头(2字节) + 长度(1字节) + 命令(1字节) + 数据(N字节) + 校验(1字节) + 帧尾(2字节)

帧头我常用0xAA 0x55,帧尾用0x0D 0x0A(回车换行)。校验用累加和,简单够用。

设计协议时,有几个坑我踩过,分享给你:

  • 帧头不要用0x00或0xFF:这些值在串口空闲时容易出现,容易误触发。
  • 长度字段要包含自身吗?:我建议不包含,这样解析时更直观。
  • 转义处理:如果数据里出现了帧头相同的字节,需要做转义。比如0xAA变成0xAA 0x01,0x55变成0xAA 0x02。

我的习惯:协议设计好后,先写一个PC端的测试工具,用Python的pyserial库模拟主机。这样可以在没有硬件的情况下验证协议的正确性。我曾经用这个方法,在硬件回来之前就把整个协议栈调通了,省了不少时间。

最后,关于协议设计,我想说:不要过度设计。很多新手喜欢把协议搞得特别复杂,各种版本号、时间戳、扩展字段。其实大多数场景下,一个简单的「帧头+命令+数据+校验」就够了。等以后需要扩展时,再升级版本也不迟。

嗯,串口通信这块,内容就这么多。从原理到实践,从标准协议到自定义设计,希望能帮你建立起一个完整的知识体系。下一章,咱们聊聊定时器和PWM,那又是另一片天地了。