2、物理层与数据链路层:RS-485总线设计、UART驱动实现、CRC校验算法、数据帧封装与解析

各位同学,咱们今天聊点硬核的。物理层和数据链路层,说白了就是嵌入式通信的「地基」。地基没打好,上层协议栈再花哨也是白搭。我在电力监控项目里见过太多「通信不稳定」的坑,追根溯源,十有八九都出在这两层。

好,咱们直接进入正题。

2.1 RS-485总线设计:不只是接两根线

RS-485,电力监控的老朋友了。抗干扰强、传输距离远、支持多节点。但很多人以为它就是「A线B线接上就能跑」,嗯,这里要注意——细节决定成败。

2.1.1 硬件设计要点

我个人习惯,RS-485设计时重点关注三件事:

  • 终端电阻:长线传输时,信号反射是头号杀手。我建议在总线两端各加一个120Ω电阻。为什么是120Ω?因为双绞线的特性阻抗大约就是这个值。匹配了,反射就小了。
  • 偏置电阻:总线空闲时,A、B线之间电压差可能不稳定。加一组上拉/下拉电阻(通常4.7kΩ~10kΩ),让空闲状态保持确定电平。我在一个变电站项目里,就因为没加偏置电阻,总线空闲时乱跳,导致误触发中断——那叫一个头疼。
  • 隔离:电力环境,共模电压可能高达几百伏。不隔离?芯片分分钟烧掉。我建议用ADI的ADM2483或TI的ISO3082这类隔离型收发器。成本高一点,但安全第一。

关键参数速查表

参数典型值说明
终端电阻120Ω匹配双绞线特性阻抗
偏置电阻4.7kΩ~10kΩ保证空闲状态电平
最大节点数32~256取决于收发器驱动能力
最大传输距离1200m9600bps时,距离越长速率越低

2.1.2 软件控制策略

RS-485是半双工的,所以收发切换很关键。我见过有人用延时来控制切换,结果数据错得一塌糊涂。我的做法是:

  • 发送前,先拉高RE/DE引脚(使能发送)
  • 发送完成后,等待最后一个字节的移位寄存器真正送完,再拉低(切回接收)
  • 这个等待时间怎么算?很简单:1个字节的传输时间 = (1/波特率) × 10位(起始位+8数据位+停止位)。比如9600bps,1字节约1.04ms。我一般等2个字节的时间,保险。

避坑指南:我曾经在一个项目中,发送完直接切接收,结果最后一个字节的停止位被截断了。对端设备死活收不到完整帧。后来用示波器一看,波形少了一截。从那以后,我都在发送函数末尾加一个「等待发送完成」的循环,检查UART的TC(Transmit Complete)标志位。

2.2 UART驱动实现:从轮询到中断

UART驱动,嵌入式工程师的入门课。但入门容易,写好难。咱们从最基础的讲起。

2.2.1 轮询模式

简单,但CPU被占死了。适合调试或低负载场景。

// 轮询发送一个字节
void uart_send_byte(uint8_t data) {
    while (!(USART1->SR & USART_SR_TXE)); // 等待发送数据寄存器空
    USART1->DR = data;
}

// 轮询接收一个字节
uint8_t uart_recv_byte(void) {
    while (!(USART1->SR & USART_SR_RXNE)); // 等待接收数据寄存器非空
    return (uint8_t)USART1->DR;
}

你看,代码就几行。但实际项目中,谁敢在主循环里死等?CPU还要干别的事呢。所以,咱们得用中断。

2.2.2 中断驱动模式

我个人习惯用环形缓冲区(Ring Buffer)配合中断。接收中断把数据塞进缓冲区,主循环从缓冲区取数据。这样收发不阻塞,CPU利用率高。

#define RX_BUF_SIZE 256
static uint8_t rx_buf[RX_BUF_SIZE];
static volatile uint16_t rx_head = 0, rx_tail = 0;

// 中断服务函数
void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_RXNE) {
        uint8_t data = (uint8_t)USART1->DR;
        uint16_t next = (rx_head + 1) % RX_BUF_SIZE;
        if (next != rx_tail) { // 缓冲区未满
            rx_buf[rx_head] = data;
            rx_head = next;
        }
        // 如果满了,直接丢弃——我建议加一个溢出计数,方便调试
    }
}

// 从缓冲区取一个字节
uint8_t uart_read_byte(uint8_t *data) {
    if (rx_head == rx_tail) return 0; // 缓冲区空
    *data = rx_buf[rx_tail];
    rx_tail = (rx_tail + 1) % RX_BUF_SIZE;
    return 1;
}

注意:中断服务函数里不要做复杂处理。我曾经见过有人在中断里调用printf,结果系统直接卡死。中断里只做数据搬运,解析工作交给主循环。

2.3 CRC校验算法:数据完整性的守护神

电力监控,数据错一个bit可能就导致误跳闸。所以CRC校验必不可少。咱们用最常用的CRC-16-Modbus。

2.3.1 查表法实现

查表法,速度快,适合嵌入式。我直接给代码,你们拿去用。

// CRC-16-Modbus 查表法
static const uint16_t crc16_table[256] = {
    0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
    // ... 完整表太长,实际使用时生成即可
};

uint16_t crc16_modbus(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc = (crc >> 8) ^ crc16_table[(crc ^ data[i]) & 0xFF];
    }
    return crc;
}

为什么初始值是0xFFFF?Modbus协议规定的。为什么最后要异或0x0000?也是协议规定的。别问,问就是标准。

个人经验:我曾经在一个项目中,CRC算出来总是不对。查了半天,发现是字节序搞反了。Modbus CRC是低字节在前,高字节在后。发送时先发低8位,再发高8位。这个顺序搞错,对端校验必失败。

2.4 数据帧封装与解析:协议栈的骨架

有了物理层和链路层,咱们得把数据组织成帧。电力监控常用的是「起始符 + 地址 + 命令 + 数据 + CRC」这种结构。

2.4.1 帧结构定义

字段长度说明
起始符1字节固定0x68
地址域2字节设备地址,低字节在前
命令码1字节如0x01读数据,0x02写数据
数据长度1字节数据域字节数
数据域N字节实际数据
CRC校验2字节CRC-16-Modbus,低字节在前

2.4.2 封装函数

uint16_t frame_pack(uint8_t *buf, uint16_t addr, uint8_t cmd, uint8_t *data, uint8_t len) {
    uint16_t idx = 0;
    buf[idx++] = 0x68;           // 起始符
    buf[idx++] = addr & 0xFF;    // 地址低字节
    buf[idx++] = (addr >> 8);    // 地址高字节
    buf[idx++] = cmd;            // 命令码
    buf[idx++] = len;            // 数据长度
    for (uint8_t i = 0; i < len; i++) {
        buf[idx++] = data[i];    // 数据域
    }
    uint16_t crc = crc16_modbus(buf, idx); // 计算CRC
    buf[idx++] = crc & 0xFF;     // CRC低字节
    buf[idx++] = (crc >> 8);     // CRC高字节
    return idx;                  // 返回总长度
}

2.4.3 解析函数

解析比封装麻烦。因为你要处理各种异常情况:帧头不对、长度不对、CRC不对。我建议用状态机来解析。

typedef enum {
    FRAME_IDLE,
    FRAME_START,
    FRAME_ADDR_L,
    FRAME_ADDR_H,
    FRAME_CMD,
    FRAME_LEN,
    FRAME_DATA,
    FRAME_CRC_L,
    FRAME_CRC_H
} frame_state_t;

static frame_state_t state = FRAME_IDLE;
static uint8_t frame_buf[256];
static uint16_t frame_idx;
static uint8_t expected_len;

uint8_t frame_parse(uint8_t byte) {
    switch (state) {
        case FRAME_IDLE:
            if (byte == 0x68) {
                state = FRAME_START;
                frame_idx = 0;
                frame_buf[frame_idx++] = byte;
            }
            break;
        case FRAME_START:
            frame_buf[frame_idx++] = byte;
            state = FRAME_ADDR_L;
            break;
        // ... 中间状态类似,略
        case FRAME_CRC_H:
            frame_buf[frame_idx++] = byte;
            // 校验CRC
            uint16_t calc_crc = crc16_modbus(frame_buf, frame_idx - 2);
            uint16_t recv_crc = (frame_buf[frame_idx-1] << 8) | frame_buf[frame_idx-2];
            if (calc_crc == recv_crc) {
                state = FRAME_IDLE;
                return 1; // 解析成功
            } else {
                state = FRAME_IDLE;
                return 0; // CRC错误
            }
        default:
            state = FRAME_IDLE;
            break;
    }
    return 0;
}

避坑指南:我曾经在一个项目中,解析状态机没做超时处理。结果总线上一帧数据被干扰,只收到半个帧,状态机卡在某个中间状态,后续所有数据都解析不对。后来我加了一个超时定时器,超过10ms没收到完整帧就复位状态机。问题解决。

2.5 小结

好了,这一章内容不少。RS-485的硬件设计、UART的驱动实现、CRC校验、帧封装解析,这些都是嵌入式电力监控协议栈的基石。你想想看,如果物理层信号都收不对,上层协议再花哨有什么用?

下一章,咱们要进入网络层了。我会讲讲怎么在这么简单的链路上,实现可靠的数据传输。到时候,我会分享一个我当年在变电站调试时遇到的「幽灵数据」问题——保证让你大开眼界。

今天就到这儿。有问题,咱们群里聊。