nanoModbus 更适合放在嵌入式网关的协议边界

在 DALI 转 Modbus 网关里,nanoModbus 的职责是处理 Modbus RTU 从站协议,并把业务逻辑留给应用层。

项目硬件是 AIR001,DALI 侧负责照明总线的发送、接收和解析,RS485 侧对外暴露 Modbus RTU。从上位机看,它就是一组 holding register;从固件看,Modbus 写寄存器会触发 DALI 发送,DALI 接收结果会回填到寄存器里。

DALI 转 Modbus 网关硬件

先把 Modbus 当成入口层

nanoModbus 只需要平台读写函数和回调表。串口、DMA、队列、业务状态都留在应用层,不塞进库里。

这个项目里,串口接收使用 DMA 环形缓冲和 idle 中断,再转存到一个字节队列。nanoModbus 的 read 回调只从这个队列里按超时时间取字节,write 回调只负责把响应发回 UART。

static int32_t nanoModbus_read_serial(uint8_t* buf,
                                      uint16_t count,
                                      int32_t byte_timeout_ms,
                                      void* arg) {
  uint32_t tick_start = HAL_GetTick();
  uint16_t realReadCount = 0;

  do {
    uint8_t rx_data;
    while (realReadCount < count &&
           USART_read(&USART1_DMA_Context, &rx_data)) {
      buf[realReadCount++] = rx_data;
    }
  } while (HAL_GetTick() - tick_start < byte_timeout_ms &&
           realReadCount < count);

  return realReadCount;
}

static int32_t nanoModbus_write_serial(const uint8_t* buf,
                                       uint16_t count,
                                       int32_t byte_timeout_ms,
                                       void* arg) {
  return USART_write(&USART1_DMA_Context, (uint8_t*)buf, count);
}

这样做的好处是边界清楚:nanoModbus 不关心 UART 是轮询、中断还是 DMA;应用层也不需要解析 Modbus 帧、CRC 和异常响应。

寄存器表要表达业务动作

DALI 网关只开放 holding register。部分寄存器是状态,例如运行时间、接收长度、接收数据;部分寄存器是配置,例如控制器模式、是否监听自身发送;还有一个寄存器是动作触发:写入发送使能后,把前面两个消息字节发到 DALI 总线。

这个设计比另起一套私有命令更容易测试。Modbus Poll 直接读写寄存器,就能验证网关的主要行为。

Modbus Poll 中的寄存器视图

寄存器地址不要散落在业务代码里。项目里用枚举固定寄存器顺序,读写回调统一检查范围和权限:

typedef enum
{
  MODBUS_HOLDING_REG_IS_MILLIS_0 = 0,
  MODBUS_HOLDING_REG_IS_MILLIS_1,
  MODBUS_HOLDING_REG_IS_CONTROLER,
  MODBUS_HOLDING_REG_TX_MSG_BOX_0,
  MODBUS_HOLDING_REG_TX_MSG_BOX_1,
  MODBUS_HOLDING_REG_TX_EN,
  MODBUS_HOLDING_REG_RX_LEN,
  MODBUS_HOLDING_REG_RX_MSG_BOX_0,
  MODBUS_HOLDING_REG_RX_MSG_BOX_1,
  MODBUS_HOLDING_REG_DEMO_SENDING_RESTRICTIONS,
  MODBUS_HOLDING_REG_DEMO_IS_MONITOR_SELF_TX,
};

读回调适合做轻量的状态整理。这个项目在读取时刷新运行时间,并在上位机读取接收消息后释放 IRQ 提示信号:

nmbs_error handler_read_holding_registers(uint16_t address,
                                          uint16_t quantity,
                                          uint16_t* registers_out,
                                          uint8_t unit_id,
                                          void* arg) {
  if (address + quantity > HOLDING_REGISTERS_COUNT)
    return NMBS_EXCEPTION_ILLEGAL_DATA_ADDRESS;

  if (address <= MODBUS_HOLDING_REG_RX_MSG_BOX_0 &&
      address + quantity >= MODBUS_HOLDING_REG_RX_MSG_BOX_1) {
    HAL_GPIO_WritePin(RX_IRQ_GPIO_Port, RX_IRQ_Pin, GPIO_PIN_SET);
  }

  *((uint32_t*)modbus_holding_registers) = HAL_GetTick();
  memcpy(registers_out,
         modbus_holding_registers + address,
         sizeof(uint16_t) * quantity);

  return NMBS_ERROR_NONE;
}

写回调要区分“写状态”和“触发动作”。接收结果、演示限制计数这类寄存器由固件维护,上位机写入应该返回非法地址。发送使能寄存器不能简单保存值,写入时要读出消息寄存器并启动 DALI 发送。

nmbs_error handler_write_single_register(uint16_t address,
                                         uint16_t value,
                                         uint8_t unit_id,
                                         void* arg) {
  if (address >= HOLDING_REGISTERS_COUNT)
    return NMBS_EXCEPTION_ILLEGAL_DATA_ADDRESS;

  switch (address) {
    case MODBUS_HOLDING_REG_RX_LEN:
    case MODBUS_HOLDING_REG_RX_MSG_BOX_0:
    case MODBUS_HOLDING_REG_RX_MSG_BOX_1:
    case MODBUS_HOLDING_REG_DEMO_SENDING_RESTRICTIONS:
      return NMBS_EXCEPTION_ILLEGAL_DATA_ADDRESS;
  }

  switch (address) {
    case MODBUS_HOLDING_REG_IS_CONTROLER:
      dali_ctx.tx_isControler = value;
      break;

    case MODBUS_HOLDING_REG_DEMO_IS_MONITOR_SELF_TX:
      dali_ctx.rx_isMonitorSelfTx = value;
      break;

    case MODBUS_HOLDING_REG_TX_EN: {
      uint8_t sendData[2] = {
        modbus_holding_registers[MODBUS_HOLDING_REG_TX_MSG_BOX_0] & 0xFF,
        modbus_holding_registers[MODBUS_HOLDING_REG_TX_MSG_BOX_1] & 0xFF
      };

      DALI_start_tx_interrupt(&dali_ctx, sendData);
      return NMBS_ERROR_NONE;
    }
  }

  modbus_holding_registers[address] = value;
  return NMBS_ERROR_NONE;
}

触发型寄存器如果被当成普通状态保存,后面维护时很容易误判设备状态。例如 TX_EN 的意义是“这次写入触发发送”,不能表示“设备当前处于发送使能状态”。这类寄存器最好在写回调里立即消费掉。

DALI 侧只通过状态和事件接入

DALI 接收完成后,不需要直接参与 Modbus 协议处理。它只把解析结果写进 holding register,并拉低 IRQ 引脚通知外部设备有新数据。

void rx_interrupt_callback(uint8_t* data, uint8_t len) {
  modbus_holding_registers[MODBUS_HOLDING_REG_RX_LEN] = len;
  modbus_holding_registers[MODBUS_HOLDING_REG_RX_MSG_BOX_0] = data[0];
  modbus_holding_registers[MODBUS_HOLDING_REG_RX_MSG_BOX_1] = data[1];
  HAL_GPIO_WritePin(RX_IRQ_GPIO_Port, RX_IRQ_Pin, GPIO_PIN_RESET);
}

这个边界让系统更容易排查。DALI 定时器、中断解析、曼彻斯特编码可以单独调;Modbus Poll 读写寄存器也可以单独调。两边通过一张小寄存器表连接。

DALI 总线波形

主循环里不要空跑协议栈

工程里还有一个细节:只有串口队列不为空时才调用 nmbs_server_poll()

if (!isQueueEmpty(&USART1_DMA_Context.queue) &&
    nmbs_server_poll(&nmbs) == NMBS_ERROR_TRANSPORT) {
  Error_Handler();
}

DALI_Update(&dali_ctx);

这样做可以避免主循环长期卡在无意义的 Modbus 读取等待里。DALI 侧还需要持续处理接收超时和帧解析,协议入口不能抢走整个主循环。

超时也要按串口接收方式理解。byte_timeout_ms 是 nanoModbus 等待下一个字节的边界,不代表 DALI 业务超时。DMA idle 中断已经负责把收到的数据搬到队列里,read 回调只要在这个边界内尽量取满即可。

这类项目的移植结论

nanoModbus 适合承担协议边界,不适合承担业务模型。嵌入式网关里最稳的用法是:

这样移植后,nanoModbus 负责协议正确性,应用代码负责设备行为。两边边界清楚,后续换串口驱动、扩寄存器、接入新的现场总线时都更容易维护。

本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。