기술 가이드

Modbus RTU 무음 간격 계산: 11비트 문자·t1.5/t3.5·19200 경계

Modbus RTU의 11비트 문자와 19,200bps 경계, t1.5·t3.5 타이머 올림을 구분하고 57,624쌍의 Python 계산으로 검증합니다.

목차

Modbus RTU에서 CRC가 맞는데도 수신 프레임이 중간에 끊기거나 두 요청이 붙는다면 무음 구간의 기준부터 확인해야 합니다. UART 바이트 수신 완료, DMA 콜백, 프레임 종료는 같은 사건이 아닙니다. 이 글은 MCU 펌웨어에서 사용할 t1.5·t3.5 타이머 설정값을 계산하고, 경계 조건을 Python으로 검증합니다.

1. 기준은 8데이터 비트가 아니라 11비트 문자

Modbus Organization의 공식 규격 목록에서 제공하는 Serial Line Protocol and Implementation Guide V1.02의 §2.5.1, 인쇄 페이지 12는 RTU 문자를 11비트로 설명합니다. 시작 1비트 + 데이터 8비트 + 패리티 1비트 + 정지 1비트입니다. 패리티가 없을 때는 정지 2비트로 11비트를 유지합니다.

따라서 표준의 8E1·8O1·8N2 설정에서 한 문자 시간은 11 / baud초입니다. 현장 장치가 8N1을 요구한다면 실제 문자 길이는 10비트이므로 표준의 문자 형식과 다릅니다. 이를 11비트와 조용히 혼용하지 말고, 장치 매뉴얼과 실제 파형을 별도로 확인해야 합니다.

2. t1.5와 t3.5는 서로 다른 판정에 사용

같은 규격 §2.5.1.1, 인쇄 페이지 13의 Figure 13과 설명을 기준으로 합니다.

  • t1.5: 같은 프레임의 두 문자 사이 무음이 1.5문자 시간을 초과하면 불완전한 프레임으로 보고 버립니다.
  • t3.5: 프레임 사이에는 적어도 3.5문자 시간의 무음이 필요합니다.
  • 19,200bps 이하: 문자 시간으로 두 타이머를 계산합니다. 19,200도 이 구간에 포함됩니다.
  • 19,200bps 초과: 규격은 처리 부하를 줄이도록 고정 타이머 사용을 설명하고, t1.5=750µs·t3.5=1.750ms를 권고합니다. 아래 예제는 이 권고를 채택합니다.

무음은 이전 문자의 정지 비트 종료부터 다음 문자의 시작 비트까지입니다. t1.5보다 길고 t3.5보다 짧은 틈을 두 정상 프레임의 경계로 처리하면 안 됩니다. 수신 상태 머신에서 불완전 프레임을 폐기하고 충분한 무음 뒤 다시 동기화해야 합니다. CRC·주소·길이 검증은 그 다음에도 필요합니다.

3. 정수 타이머는 먼저 식을 합치고 마지막에 올림

19,200bps 이하에서 타이머 주파수를 fHz라 하면 필요한 카운트는 다음과 같습니다.

t1.5_counts = ceil(33 * f / (2 * baud))
t3.5_counts = ceil(77 * f / (2 * baud))

먼저 11 / baud를 정수로 잘라 버린 뒤 1.5·3.5를 곱하면 오차가 누적됩니다. 송신 전 최소 무음 확보에는 최종 카운트를 올림해 일찍 만료되는 것을 피합니다. 아래는 1MHz, 즉 한 카운트가 1µs일 때의 값입니다.

속도(bps) t1.5 카운트(µs) t3.5 카운트(µs) 적용 기준
1,200 13,750 32,084 문자 시간
2,400 6,875 16,042 문자 시간
4,800 3,438 8,021 문자 시간
9,600 1,719 4,011 문자 시간
19,200 860 2,006 문자 시간
19,201 750 1,750 고정값 권고 채택
38,400 750 1,750 고정값 권고 채택
115,200 750 1,750 고정값 권고 채택

19,201bps는 분기 검사용 입력이며 실제 UART가 그 속도를 정확히 만들 수 있다는 뜻은 아닙니다. 9,600bps의 실제 t3.5는 약 4,010.417µs입니다. 4,010으로 내리면 최소 간격보다 짧아지고, 4,011로 올리면 약 0.583µs 길어집니다.

4. 복사해서 실행하는 계산·검증 예제

Python 3.10 이상에서 외부 패키지 없이 실행합니다. 다음 전체 내용을 rtu_timing.py로 저장한 뒤 python -B rtu_timing.py를 실행하세요. 계산 함수는 정수 산술을 쓰고, 검증에서는 별도의 Fraction 유리수 연산으로 올림 결과와 오차가 한 카운트 미만인지 확인합니다.

from fractions import Fraction
from math import ceil


def positive_int(value):
    if type(value) is not int or value <= 0:
        raise ValueError("positive integer required")


def ceil_div(n, d):
    return (n + d - 1) // d


def rtu_timer_counts(baud, timer_hz=1_000_000):
    positive_int(baud)
    positive_int(timer_hz)
    if baud > 19_200:
        return (ceil_div(750 * timer_hz, 1_000_000),
                ceil_div(1750 * timer_hz, 1_000_000))
    # 1.5 * 11 = 33/2; 3.5 * 11 = 77/2 bits
    return (ceil_div(33 * timer_hz, 2 * baud),
            ceil_div(77 * timer_hz, 2 * baud))


def self_test():
    expected_us = {
        1200: (13750, 32084),
        2400: (6875, 16042),
        4800: (3438, 8021),
        9600: (1719, 4011),
        19200: (860, 2006),
        19201: (750, 1750),
        38400: (750, 1750),
        115200: (750, 1750),
    }
    for baud, expected in expected_us.items():
        assert rtu_timer_counts(baud) == expected

    checked = 0
    high_rates = (19201, 28800, 38400, 57600,
                  115200, 230400, 460800, 921600)
    for hz in (1000, 32768, 1_000_000):
        for baud in (*range(1, 19201), *high_rates):
            exact = ((Fraction(750 * hz, 1_000_000),
                      Fraction(1750 * hz, 1_000_000))
                     if baud > 19200 else
                     (Fraction(33 * hz, 2 * baud),
                      Fraction(77 * hz, 2 * baud)))
            counts = rtu_timer_counts(baud, hz)
            assert counts == tuple(ceil(x) for x in exact)
            for count, minimum in zip(counts, exact):
                assert count - 1 < minimum <= count
            checked += 1

    for bad in (0, -1, True, 9600.0, "9600", None):
        for args in ((bad, 1_000_000), (9600, bad)):
            try:
                rtu_timer_counts(*args)
            except ValueError:
                pass
            else:
                raise AssertionError("invalid input accepted")
    print(f"PASS: {checked} timing pairs; 12 invalid inputs")
    for baud, expected in expected_us.items():
        print(baud, *expected)


if __name__ == "__main__":
    self_test()

첫 출력은 다음과 같습니다. 뒤에는 위 표의 8개 속도 값이 출력됩니다.

PASS: 57624 timing pairs; 12 invalid inputs
1200 13750 32084
2400 6875 16042
4800 3438 8021
9600 1719 4011
19200 860 2006
19201 750 1750
38400 750 1750
115200 750 1750

검증은 1~19,200bps 전체 정수 입력과 고속 입력 8개를 1kHz·32,768Hz·1MHz 타이머로 각각 계산한 총 57,624쌍입니다. 1kHz도 계산 자체는 검사하지만, 낮은 타이머 해상도를 실제 RTU 수신 판정에 권장하는 것은 아닙니다. 750µs를 1ms로 양자화하면 경계 식별 정밀도가 크게 떨어집니다. 이 검증은 호스트 계산 시험이며 실제 MCU·RS485 장비에서 실행한 결과는 아닙니다.

5. MCU에 옮길 때 확인할 부분

타임스탬프의 위치를 맞춥니다. 이전 문자와 다음 문자의 수신 완료 시각 차이에는 다음 문자 전송 시간도 들어갑니다. 그 차이를 그대로 무음 시간으로 비교하지 마세요. 하드웨어가 제공하는 IDLE·Receiver Timeout의 시작 기준과 단위를 레퍼런스 매뉴얼에서 확인하고, 마지막 정지 비트와 다음 시작 비트 사이를 로직 애널라이저로 대조합니다. DMA 버퍼 전달 시각이나 PC USB 시리얼 read 청크 도착 시각은 선로의 문자 간격을 직접 나타내지 않습니다.

송신 완료 플래그를 구분합니다. DMA 전송 완료나 송신 데이터 레지스터 비움만 보고 RS485 DE를 내리면 마지막 정지 비트가 잘릴 수 있습니다. UART가 실제 전송을 끝냈음을 나타내는 조건과 트랜시버 전파 지연을 확인하세요. 프레임 사이 최소 무음도 실제 선로의 전송 종료를 기준으로 잡습니다.

올림은 수신 판정의 모든 오차를 해결하지 않습니다. t1.5의 초과 판정, t3.5의 이상 판정은 같지 않습니다. 타이머 위상·양자화·클록 오차·인터럽트 지연을 포함한 오차 예산이 필요합니다. 예제의 올림값을 실제 수신 상태 머신의 완전한 규격 판정기로 간주하면 안 됩니다. 1카운트가 임계값 주변에서 어떤 허용 오차를 만드는지도 측정합니다.

MCU 정수 폭과 타이머 레지스터를 확인합니다. Python 정수와 달리 C의 77 * timer_hz는 32비트에서 넘칠 수 있으므로 중간 곱셈에 uint64_t 등을 사용합니다. 함수가 반환한 값은 카운트 수이며, ARR·비교 레지스터에 그대로 쓸 값인지 counts-1인지 여부는 해당 타이머의 계수 방식에 따라 결정합니다. 카운터 폭, 프리스케일러, 실제 주변장치 클록, 저전력 모드에서의 동작도 확인하세요.

6. 현장 확인 순서

  1. 양쪽 장치의 RTU 모드·baud·패리티·정지 비트를 일치시킵니다.
  2. 9,600·19,200·38,400bps에서 마지막 정지 비트와 다음 시작 비트를 측정합니다.
  3. 프레임 내부에 t1.5를 넘는 공백이 생기는지, 새 프레임 전 t3.5를 확보하는지 각각 확인합니다.
  4. 프레임 폐기 후 재동기화, UART 오류, 버퍼 초과, CRC 오류를 서로 다른 카운터로 기록합니다.
  5. 슬레이브 처리 지연을 포함한 응답 타임아웃을 따로 설정합니다. t3.5는 요청을 처리하고 응답을 끝낼 시간의 한도가 아닙니다.

근거는 공식 V1.02의 §2.4.1(응답 타임아웃), §2.5.1(11비트 문자), §2.5.1.1(무음 간격)입니다. 공식 PDF를 함께 확인하고, CRC 구현은 사이트의 Modbus RTU CRC-16 가이드와 연결해 점검할 수 있습니다.

관련 서비스

관련 글

댓글

첫 댓글을 남겨 보세요.

관리자 확인 후 게시됩니다.

처리 중입니다...

잠시만 기다려주세요.