为什么要单独写一个 RS485 串口驱动
PipeMonitor 的上行链路是这样一条路径:
STM32 USART3 (115200bps) → MAX485 → DR154 4G DTU → MQTT Broker
DR154 工作在透传模式,STM32 这一头看到的就是一个普通的串口,但物理层是 RS485 半双工。RT-Thread 自带的 rt_device_write 只负责把字节塞进 TDR,并不知道 MAX485 的 DE 脚什么时候该拉高、什么时候该放。如果直接用框架 API 发送,会出现两个让人头疼的现象:
- 帧头几个字节被截掉(DE 还没拉高就开始 write)
- 帧尾最后一个字节被截掉(DE 提前拉低,移位寄存器里的数据还没出去)
另外,半双工 RS485 在发送期间,MAX485 的 RO 脚会把自己发出去的字节回读进来,如果 RX 中断没关,这些回声会污染下行缓冲。这些问题框架不会帮你处理,只能在驱动层手写。
uplink_port.c 就是这一层。它和 modbus_port.c(传感器的 RS485 总线)共享同一套设计思路,但针对 DTU 透传场景做了调整:波特率 115200、按换行分帧、增加了 JSON 完整性检测。
MAX485 DE 方向控制与保护延时
MAX485 的 DE(Driver Enable)高电平 = 发送模式,/RE 通常和 DE 短接在一起,所以一根 GPIO 就能切换收发方向。DE 脚接在 PB14:
#define UPLINK_PORT_DE_PORT GPIOB
#define UPLINK_PORT_DE_PIN GPIO_PIN_14
#define UPLINK_PORT_DE_GUARD_MS 2
初始化时先拉低 DE,保证上电瞬间不会误占总线:
static void uplink_port_init_de_gpio(void)
{
GPIO_InitTypeDef gpio = {0};
UPLINK_PORT_DE_CLK_ENABLE();
/* 默认接收态,避免上电或初始化阶段误占 RS485 总线。 */
HAL_GPIO_WritePin(UPLINK_PORT_DE_PORT, UPLINK_PORT_DE_PIN, GPIO_PIN_RESET);
gpio.Pin = UPLINK_PORT_DE_PIN;
gpio.Mode = GPIO_MODE_OUTPUT_PP;
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(UPLINK_PORT_DE_PORT, &gpio);
}
发送时的时序是:拉高 DE → 等 2ms → rt_device_write → 等 TC → 等 2ms → 拉低 DE。前后各 2ms 的保护延时是经验值,和 modbus_port.c 保持一致。前延时给 MAX485 内部驱动器建立时间,后延时确保最后一个 bit 真正落到线上再切回接收。
关键:等 TC 才能放 DE
这是整个驱动里最容易踩坑的点。rt_device_write 返回后,只代表字节被写进了 TDR(发送数据寄存器),但字节还要经过移位寄存器一位一位地移出去。如果此时立刻拉低 DE,移位寄存器里的最后一个字节会被硬截断,对端收到的就是少了一个字节的残帧。
USART 的 TC(Transmission Complete)标志正是为这个场景准备的:当移位寄存器也空了,TC 置位。所以发送完必须死等 TC:
written = rt_device_write(g_serial, 0, buf, count);
g_write_count++;
g_last_write_req = count;
g_last_write_ret = (int32_t)written;
g_last_write_sr = UPLINK_PORT_UART->SR;
/* 关键:最后一个字节必须通过 TC 确认落线,才能放 DE;
否则 DE 拉低会把移位寄存器中的尾部数据硬截断。 */
deadline = rt_tick_get() + rt_tick_from_millisecond(UPLINK_PORT_TX_DONE_TIMEOUT_MS);
while ((UPLINK_PORT_UART->SR & USART_SR_TC) == 0U)
{
if ((rt_int32_t)(deadline - rt_tick_get()) <= 0)
{
g_tc_timeout_count++;
g_write_fail_count++;
g_last_write_sr = UPLINK_PORT_UART->SR;
rt_kprintf("[uplink_port] wait TC timeout SR=0x%08X\n", (unsigned)g_last_write_sr);
uplink_port_set_de(0);
uplink_port_drain_hw();
if (rx_irq_enabled != RT_FALSE)
{
UPLINK_PORT_UART->CR1 |= USART_CR1_RXNEIE;
}
return -2;
}
}
TC 等待加了 100ms 超时兜底。115200bps 下一个字节大约 87us,100ms 足够覆盖任何正常情况。如果真超时了,说明 UART 出了硬件级问题(比如时钟挂了),这时不能死等,要拉低 DE、清硬件残留、恢复 RXNE 中断,返回错误码让上层处理。
发送期间屏蔽 RXNE 中断
半双工 RS485 在发送时,MAX485 的 RO 脚会输出本机刚发出的字节(自回环)。如果 RXNE 中断开着,这些回声会被当作下行数据塞进缓冲,污染下一轮 read_line。更麻烦的是,某些自动流向模块在 DE 翻转瞬间会产生尖峰噪声,触发 FE/NE 等错误标志。
解决方法很直接:发送窗口内关掉 RXNEIE,发完再开:
/* 半双工/自动流向模块在本机发送期间可能把回读或转向噪声送到 PB11。
* 发送窗口内临时关闭 RXNE 中断,发送完成后清掉硬件残留,再恢复接收。 */
rx_irq_enabled = ((UPLINK_PORT_UART->CR1 & USART_CR1_RXNEIE) != 0U) ? RT_TRUE : RT_FALSE;
if (rx_irq_enabled != RT_FALSE)
{
UPLINK_PORT_UART->CR1 &= ~USART_CR1_RXNEIE;
g_rx_muted_tx_count++;
}
注意这里是先记录原状态再关,因为上层可能因为某些场景已经手动关过 RXNEIE,恢复时要尊重原状态,不能无脑开。发完之后调 uplink_port_drain_hw() 把这段时间累积在 DR 里的回声和错误标志一起清掉,再恢复中断。
F1 错误标志清除顺序:先读 SR 再读 DR
STM32F1 的 USART 有一个反直觉的硬件行为:ORE(Overrun)、NE(Noise Error)、FE(Framing Error)、PE(Parity Error)这几个错误标志,清除顺序必须是先读 SR 再读 DR,顺序反了或者只读其中一个都清不掉。这是 F1 参考手册里明确写的,但很容易被忽略。
uplink_port_drain_hw() 封装了这个清除流程:
static void uplink_port_drain_hw(void)
{
uint32_t sr = UPLINK_PORT_UART->SR;
uplink_port_note_rx_errors(sr);
if ((sr & (USART_SR_ORE | USART_SR_NE | USART_SR_FE | USART_SR_PE | USART_SR_IDLE)) != 0U)
{
/* F1 清 RX 错误/IDLE 标志的顺序必须是先读 SR 再读 DR。 */
volatile uint32_t dummy_sr = UPLINK_PORT_UART->SR;
volatile uint32_t dummy_dr = UPLINK_PORT_UART->DR;
(void)dummy_sr;
(void)dummy_dr;
}
if ((sr & USART_SR_TC) != 0U)
{
UPLINK_PORT_UART->SR = (uint16_t)~USART_SR_TC;
}
while ((UPLINK_PORT_UART->SR & USART_SR_RXNE) != 0U)
{
volatile uint32_t dummy = UPLINK_PORT_UART->DR;
(void)dummy;
g_rx_hw_drain_bytes++;
}
}
这里有个细节:读 SR 和读 DR 都用 volatile uint32_t 接收,避免编译器优化掉这次读操作。如果不用 volatile,编译器发现这个值没被使用,可能直接删掉这条语句,标志就清不掉了。TC 标志的清除方式不同,写 SR 清除即可。最后还有一个 while 循环把 RXNE 里残留的字节全部读出来扔掉,防止缓冲里有陈旧数据。
这个函数在三个时机被调用:初始化后、每次发送完成后、TC 超时错误路径。任何一次遗漏都会导致后续 RX 行为异常。
JSON 完整性检测:兼容下行缺换行
DTU 透传模式下,帧边界靠 \n 切分。但实际现场遇到过一个问题:DR154 透传 MQTT payload 时,有时下行 JSON 没有附带换行符,导致 read_line 一直等 \n,ACK 窗口超时,整条链路假死。
为了兼容这种情况,加了一个 JSON 完整性检测器:在缓存字节的过程中,如果检测到顶层 JSON 对象已经闭合({...} 配平),即使没收到 \n 也立即成帧。检测逻辑要处理字符串内的转义,否则 "a\"}" 里的 } 会被误判为对象闭合:
static rt_bool_t uplink_port_cache_json_complete(void)
{
rt_size_t i;
int depth = 0;
rt_bool_t started = RT_FALSE;
rt_bool_t in_string = RT_FALSE;
rt_bool_t escaped = RT_FALSE;
/* DR154 透传 MQTT payload 时通常是一条 JSON。兼容下行缺少换行的场景:
* 顶层 JSON 对象闭合后也视为一帧,避免一直等 '\n' 导致 ACK 超时。 */
for (i = 0U; i < g_line_cache_pos; i++)
{
char ch = g_line_cache[i];
if (started == RT_FALSE)
{
if ((ch == ' ') || (ch == '\t') || (ch == '\r') || (ch == '\n'))
{
continue;
}
if (ch != '{')
{
return RT_FALSE;
}
started = RT_TRUE;
depth = 1;
continue;
}
if (escaped != RT_FALSE)
{
escaped = RT_FALSE;
continue;
}
if (ch == '\\')
{
escaped = in_string;
continue;
}
if (ch == '"')
{
in_string = (in_string == RT_FALSE) ? RT_TRUE : RT_FALSE;
continue;
}
if (in_string != RT_FALSE)
{
continue;
}
if (ch == '{')
{
depth++;
}
else if (ch == '}')
{
depth--;
if (depth == 0)
{
return RT_TRUE;
}
}
}
return RT_FALSE;
}
关键点:\\ 只在字符串内才算转义符,在字符串外遇到 \\ 不进入 escaped 状态;" 翻转 in_string 状态;只有不在字符串内时才统计 {/} 深度。这套状态机虽然不到 60 行,但能正确处理嵌套对象、字符串内的花括号、转义引号等情况。
read_line 里每收到一个字节就调一次这个检测器,命中就立刻 emit 当前缓存行:
if (g_line_cache_pos < (rt_size_t)(sizeof(g_line_cache) - 1U))
{
g_line_cache[g_line_cache_pos++] = (char)byte;
if (uplink_port_cache_json_complete() != RT_FALSE)
{
return uplink_port_emit_cached_line(buf, max_len, RT_TRUE);
}
}
这样既保留了 \n 分帧的兼容性,又解决了下行缺换行的边界问题。
行缓冲溢出恢复
g_line_cache 是 256 字节的固定缓冲。如果对端发来一串没有换行的垃圾数据,缓冲会满。这时不能直接覆盖,否则下一帧合法数据会和残留垃圾混在一起。策略是:缓冲满后丢弃后续字节直到下一个 \n,强制重新同步:
else
{
/* 行过长,丢弃到下一行起点以恢复同步 */
g_line_cache_pos = 0U;
g_line_drop_until_lf = RT_TRUE;
g_rx_line_overflow_count++;
}
g_line_drop_until_lf 置位后,后续字节全部跳过,直到收到 \n 才清标志重新开始缓存。这保证了即使某一帧异常超长,下一帧也能干净地恢复。计数器 g_rx_line_overflow_count 记录溢出次数,现场调试时能快速定位是不是对端发错格式。
大量调试计数器
驱动里埋了一组计数器,覆盖了发送和接收路径的每个关键节点:
static uint32_t g_write_count = 0U;
static uint32_t g_write_fail_count = 0U;
static uint32_t g_tc_timeout_count = 0U;
static uint32_t g_rx_indicate_count = 0U;
static uint32_t g_rx_indicate_bytes = 0U;
static uint32_t g_rx_read_bytes = 0U;
static uint32_t g_rx_line_count = 0U;
static uint32_t g_rx_line_overflow_count = 0U;
static uint32_t g_rx_json_frame_count = 0U;
static uint32_t g_rx_empty_line_count = 0U;
static uint32_t g_rx_fe_count = 0U; /* Framing Error */
static uint32_t g_rx_ne_count = 0U; /* Noise Error */
static uint32_t g_rx_ore_count = 0U; /* Overrun Error */
static uint32_t g_rx_pe_count = 0U; /* Parity Error */
static uint32_t g_rx_hw_drain_bytes = 0U;
static uint32_t g_rx_muted_tx_count = 0U;
RX 中断回调里每次都读 SR 并统计错误:
static rt_err_t uplink_port_rx_indicate(rt_device_t dev, rt_size_t size)
{
uint32_t sr = UPLINK_PORT_UART->SR;
(void)dev;
/* 记录 USART3 RX 中断路径是否真的进来,便于区分硬件问题和上层解析问题。 */
g_rx_indicate_count++;
g_rx_indicate_bytes += (uint32_t)size;
g_rx_last_sr = sr;
uplink_port_note_rx_errors(sr);
rt_sem_release(&g_rx_sem);
return RT_EOK;
}
这套计数器通过 msh 命令 uplink_port_stat 一次性 dump 出来。现场出问题时,看几个数字就能定位:
rx_fe高 → 波特率不匹配或线缆干扰rx_ore高 → 中断响应太慢,CPU 被高优先级占用rx_irq不增长 → NVIC 或 GPIO 配置丢了tc_timeout> 0 → UART 时钟或硬件异常rx_overflow> 0 → 对端发了超长帧或格式错误
另外还保存了 g_last_write_sr、g_rx_last_sr、g_rx_last_line 这些”最后一次状态”,配合 hex 预览,能在不开调试器的情况下还原事故现场。这套设计是 modbus_port.c 那一套的延伸,在多次现场联调中省下了大量插拔调试器的时间。
小结
- MAX485 DE 方向控制要前后各加保护延时,前延时等驱动器建立,后延时等最后一个 bit 落线
rt_device_write返回不代表发送完成,必须死等 TC 标志才能拉低 DE,否则帧尾被截断- 半双工发送期间要屏蔽 RXNE 中断,避免回环噪声污染下行缓冲,发完再 drain 硬件残留
- F1 清除 USART 错误标志的顺序是先读 SR 再读 DR,且读操作必须用 volatile 防优化
- JSON 完整性检测用大括号配平 + 字符串转义感知,兼容 DTU 下行缺换行的场景
- 行缓冲溢出后丢弃到下一个换行符强制重新同步,避免残帧污染
- 大量调试计数器覆盖收发全路径,现场靠 msh 命令就能定位大部分问题