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 | 取决于收发器驱动能力 |
| 最大传输距离 | 1200m | 9600bps时,距离越长速率越低 |
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校验、帧封装解析,这些都是嵌入式电力监控协议栈的基石。你想想看,如果物理层信号都收不对,上层协议再花哨有什么用?
下一章,咱们要进入网络层了。我会讲讲怎么在这么简单的链路上,实现可靠的数据传输。到时候,我会分享一个我当年在变电站调试时遇到的「幽灵数据」问题——保证让你大开眼界。
今天就到这儿。有问题,咱们群里聊。