호스트 로그에 T3 타임아웃이 찍혔다. S1F1을 보냈는데 S1F2가 안 왔다는 것이다. 그런데 와이어를 떠 보면 나간 헤더가 00 0B 01 01 00 00 00 00 0A 03이다. 세 번째 바이트가 01. 장비는 답을 안 한 게 아니라, 답하지 말라는 요청을 받은 것이다. HSMS 헤더에서 Stream과 W-bit은 한 바이트에 같이 산다. SEMI E37은 헤더 10바이트를 0부터 세는데, 그 바이트 2 — 눈으로는 세 번째 — 의 최상위 비트가 W-bit이고 나머지 7비트가 Stream이다. 이 비트 하나가 빠지면 증상은 "장비가 응답을 안 한다"로 나타나고, 그 로그를 들고 장비 업체에 전화를 걸게 된다.
한 비트만 다른 두 번의 S1F13
시뮬레이터 EQ1의 passive 리스너(127.0.0.1:5501)에 밖에서 파이썬 소켓으로 붙었다. TCP 연결 하나에 전부 올렸고, 아래 hex는 전부 실제로 소켓에 오간 바이트다. 장비 쪽 로그에 RX로 남은 것만 적었다. 시간은 클라이언트에서 잰 값이다. 2026-09-19 캡처, fault는 하나도 켜지 않았고({}) 끝나고 다시 {}인 것을 확인했다. 원본 export는 content/demos/hsms-wbit-header-byte-2.json에 있다.
E37은 길이 접두어 4바이트 뒤의 헤더 10바이트를 0부터 센다.
| offset | 필드 | 데이터 메시지(SType 0)에서 |
|---|---|---|
| 0–1 | SessionID | 00 0B = 11 |
| 2 | W-bit + Stream | 비트 7이 W-bit, 하위 7비트가 Stream |
| 3 | Function | 0D = 13 |
| 4 | PType | 00 = 바디가 SECS-II 인코딩 |
| 5 | SType | 00이면 데이터 메시지 |
| 6–9 | SystemBytes | 응답이 그대로 돌려준다 |
먼저 Select를 끝낸다.
06:03:55.044 TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 04 01
06:03:55.046 RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 04 01 1.9 ms
SType이 01/02라 제어 메시지다. 제어 메시지에서는 바이트 2가 W-bit + Stream 자리가 아니고, 바이트 3은 Select Status를 싣는다 — 여기서는 00, 즉 accepted(Select.rsp 읽는 법). 여기까지가 SELECTED.
이제 같은 연결에서 S1F13을 두 번 보낸다. 바이트 2와 SystemBytes 말고는 완전히 같은 메시지다.
06:03:55.046 TX S1F13 W=1 00 00 00 1A 00 0B 81 0D 00 00 00 00 04 02 01 02 41 07 48 4F 53 54 2D 30 31 41 03 31 2E 30
06:03:55.047 RX S1F14 00 00 00 37 00 0B 01 0E 00 00 00 00 04 02 01 02 21 01 00 01 02 41 15 ... 1.3 ms
81 = 1000 0001. 비트 7이 1이니 W-bit, 하위 7비트가 Stream 1. 바이트 3의 0D가 Function 13이다. 돌아온 S1F14는 바이트 2가 01 — 같은 Stream 1인데 W-bit은 0이다. secondary는 답을 요구하지 않으니 당연하고, 바디 앞머리의 21 01 00이 COMMACK 0이다. SystemBytes 00 00 04 02는 그대로 돌아왔다.
06:03:55.047 TX S1F13 W=0 00 00 00 1A 00 0B 01 0D 00 00 00 00 04 03 01 02 41 07 48 4F 53 54 2D 30 31 41 03 31 2E 30
06:04:05.057 (응답 없음) 10,010.1 ms 침묵, 소켓은 그대로 열려 있음
81이 01로 바뀐 것뿐이다. Stream도 1, Function도 13, 바디도 같은 바이트. 장비 export에는 이 프레임이 RX, wbit: false로 정확히 남아 있고 그 뒤에 E→H가 없다. 도착은 했고, 답을 안 한 것이다. 10초 넘게 기다렸지만 상대는 소켓을 끊지도 않았다.
S1F1로도 같은 짓을 했다.
06:04:05.057 TX S1F1 W=1 00 00 00 0A 00 0B 81 01 00 00 00 00 04 04
06:04:05.058 RX S1F2 00 00 00 32 00 0B 01 02 00 00 00 00 04 04 01 02 41 15 ... 0.6 ms
06:04:05.058 TX S1F1 W=0 00 00 00 0A 00 0B 01 01 00 00 00 00 04 05
06:04:15.068 (응답 없음) 10,010.2 ms 침묵
바디 없는 S1F1 열네 바이트짜리다. W를 켜면 0.6 ms 만에 MDLN과 SOFTREV가 담긴 S1F2가 오고, 끄면 아무것도 안 온다. 두 번 다 바이트 2의 최상위 비트 하나 차이다.
10,010 ms는 내 클라이언트의 recv 타임아웃이지 규격의 타이머 값이 아니다. T3는 아직 터지지도 않았다.
W-bit이 정하는 것
SEMI E5에서 홀수 function은 primary, 짝수는 그에 대한 secondary다. W-bit은 그 primary를 보낸 쪽이 secondary를 기다릴 것인지를 상대에게 알리는 한 비트다. W=1이면 상대는 대응하는 secondary를 돌려줘야 하고, W=0이면 돌려주면 안 된다. 위 캡처에서 장비가 한 게 정확히 그거다.
그래서 S1F3을 W=0으로 내보내고 S1F4를 기다리는 호스트는 T3 만료까지 규격대로 정확히 기다린다. T3는 E5와 E30이 쓰는 응답 타임아웃이고, 기본값 45초에 범위는 1–120초다. 45초를 꽉 채운 타임아웃 로그는 대개 이 T3다. 아무도 규칙을 어기지 않았고, 장비는 요청받은 대로 조용히 있었을 뿐이다.
이게 나오는 경로는 대개 둘 중 하나다. 하나는 라이브러리에 send_and_wait와 send_no_wait가 따로 있는데 W 설정은 메시지 빌더 쪽에 있어서, 기다리는 API로 보내면서 W를 안 켠 메시지를 넘기는 경우. 다른 하나는 헤더를 직접 조립하면서 byte2 = stream이라고 쓴 경우다. Stream 1을 1로 넣은 건 맞다. W가 없을 뿐이다.
반대 실수: 마스크 없이 읽기
수신 쪽에서 바이트 2를 그대로 Stream으로 읽으면 W=1인 S1F1은 S129F1이 된다. 0x81이 129니까. 그런 Stream은 없다. 로그에 "unknown stream 129"가 찍히거나 조용히 버려지는데, 어느 쪽이든 호스트 입장에서는 보낸 메시지가 사라진 것으로 보인다.
시뮬레이터의 디코더는 stream = byte2 & 0x7F, wbit = byte2 & 0x80으로 읽는다. 위 캡처의 export를 보면 0x81을 stream 1 / wbit true로, 0x01을 stream 1 / wbit false로 갈라 놓았다. 마스크를 안 씌우고 비교하는 자리가 파서에 한 군데라도 있으면 W=1 메시지가 전부 거기서 미아가 된다. 그리고 그건 대개 S1F1이나 S1F13처럼 대화를 여는 primary들이라 증상이 크게 나타난다.
E5에는 이런 상황을 알리는 stream 9가 있다. 인식하지 못한 Stream이면 S9F3, 인식하지 못한 Function이면 S9F5다. 다만 S9를 실제로 내보내는지는 장비마다 다르고 이번 캡처로 확인된 것도 아니다. 상대가 조용하다고 해서 메시지가 잘 도착했다는 뜻은 아니라는 것만 기억하면 된다.
짝수 function에 W를 켜면
S6F12는 S6F11에 대한 응답, 즉 secondary다. E5에서 secondary의 W-bit은 0이다. 응답에 대한 응답을 요구하는 셈이라 성립하지 않는다. 그래도 켜서 보내 봤다.
06:04:15.068 TX S6F12 W=1 00 00 00 0D 00 0B 86 0C 00 00 00 00 04 06 21 01 00
06:04:15.069 RX S6F0 00 00 00 0A 00 0B 06 00 00 00 00 00 04 06 1.0 ms
86 = W-bit 1 + Stream 6, 바이트 3의 0C가 Function 12다. 돌아온 건 바이트 2 06, 바이트 3 00 — Stream 6 Function 0, 바디 없는 SxF0다. E5에서 function 0은 트랜잭션을 접겠다는 abort 메시지다. 이 시뮬레이터가 그렇게 골랐을 뿐이고 모든 장비가 이렇게 답한다는 뜻은 아니다. 다른 장비는 그냥 버릴 수도 있다.
실무에서 이건 보통 "나가는 메시지는 전부 W를 켠다"고 쓴 래퍼 함수 하나에서 나온다. 이벤트 보고를 확인하는 S6F12, 알람의 S5F2 같은 게 전부 이 함수를 타면서 잘못된 헤더로 나간다.
호스트 코드에서 볼 자리
- 헤더 조립은
byte2 = (stream & 0x7F) | (응답을 기다릴 거면 0x80 아니면 0). Stream을 그냥 넣지 않는다. - 파싱은
stream = byte2 & 0x7F. 마스크는 한 군데도 빠지면 안 된다. - secondary(짝수 function)를 만들 때 W-bit은 0.
- W=0으로 보낸 트랜잭션에는 T3 타이머를 걸지 않는다. 걸면 정상 동작이 매번 타임아웃으로 기록되고, 진짜 T3가 터졌을 때 그게 로그에 묻힌다. 어느 타이머가 터진 건지는 타이머별로 정리해 뒀다.
- 응답 매칭은 SystemBytes로 한다. 순서로 매칭하는 코드는 응답이 뒤섞이는 순간 무너진다.
T3 타임아웃을 봤을 때 첫 질문은 "나간 헤더의 바이트 2 최상위 비트가 켜져 있었나"다. 이건 로그가 아니라 캡처를 봐야 안다. 호스트 로그는 대개 S1F1 sent라고만 쓰고, 그 문자열은 W-bit이 켜졌든 꺼졌든 똑같이 찍히기 때문이다.
위 캡처는 SECS/GEM 시뮬레이터의 passive 리스너를 상대로 만들었다. 소켓을 열고 바이트 2의 81을 01로만 바꿔 보내면 같은 침묵이 나온다.