为什么要单独写一个 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_srg_rx_last_srg_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 命令就能定位大部分问题

后续阅读