← 전체 글
SECS/GEM/약 14분 읽기/— 조회

SECS-II 길이 바이트가 세는 건 바이트다, List만 빼고

S1F3 본문을 실제 소켓에 실어 본 캡처. 아이템 헤더 한 바이트가 포맷 코드와 길이 바이트 수를 어떻게 나눠 갖는지, 길이가 1 틀리면 무슨 일이 나는지.

SECS/GEMMESSCADA문제 해결체크리스트

S1F3을 던졌는데 장비가 조용하다. 호스트 로그에는 T3 타임아웃 한 줄뿐이고, 캡처에는 내가 보낸 24바이트가 그대로 나가 있다. 이쯤 되면 본문 hex를 직접 읽는 수밖에 없다. 앞의 10바이트 헤더는 SEMI E37이 정하고, 그 뒤에 붙는 본문은 SEMI E5가 정한다. E5 쪽 규칙은 짧다. 아이템은 헤더 바이트 하나로 시작하고, 그 한 바이트가 포맷 코드와 길이 바이트 수를 같이 들고 있다.

S1F3 본문 14바이트 — List 헤더 2바이트와 U4 아이템 두 개, 그리고 아이템 헤더 바이트 B1이 포맷 코드 101100과 길이 바이트 수 01로 나뉘는 모습 S1F3 본문 — 14 bytes List 헤더 아이템 1 — U4 101 아이템 2 — U4 102 0102 0400 000065 0400 000066 B1B1 원소 2개 길이 4 길이 4 아이템 헤더 바이트 101 100 01 포맷 코드 101100 = U4 길이 바이트 1개

헤더 바이트 한 개에 두 가지가 들어 있다

E5의 아이템 헤더는 상위 6비트가 포맷 코드, 하위 2비트가 뒤에 따라오는 길이 바이트의 개수다. 길이 바이트는 1개, 2개, 3개 중 하나다. 0개는 없다. 그래서 아이템 하나의 본문은 최대 2^24-1 바이트고, 그보다 큰 걸 보내야 하면 아이템을 쪼개는 것 말고 방법이 없다.

포맷 코드는 E5가 8진수로 준다. 현장에서 실제로 보이는 것만 추리면 이 정도다.

8진 코드타입헤더 바이트 (길이 1개일 때)
00List01
010Binary21
011Boolean25
020ASCII41
030 / 031 / 032 / 034I8 / I1 / I2 / I461 / 65 / 69 / 71
040 / 044F8 / F481 / 91
050 / 051 / 052 / 054U8 / U1 / U2 / U4A1 / A5 / A9 / B1

계산은 (포맷 코드 << 2) | 길이 바이트 수다. U4는 8진 054, 10진으로 44, 44를 두 칸 밀면 0xB0, 여기에 길이 바이트 1개를 더해 B1. ASCII는 8진 020, 즉 16, 밀면 0x40, 더하면 41. SECS 트레이스를 몇 번 본 사람이면 41은 눈에 익을 것이다. 문자열 아이템의 시작이다. B1을 비트로 펴면 1011 0001이고, 위 6비트 101100이 포맷 코드 U4, 아래 2비트 01이 길이 바이트 1개라는 뜻이다.

함정은 길이 바이트가 무엇을 세느냐다. List가 아닌 아이템에서 길이는 바이트 수다. List에서는 원소 개수다. 01 02는 2바이트짜리 리스트가 아니라 원소 두 개짜리 리스트고, 그 두 원소가 각각 몇 바이트인지는 자기 헤더를 읽어야 안다. 인코더를 직접 짜다가 이걸 섞으면 짧은 메시지에서는 우연히 맞고 중첩 리스트에서 틀어진다.

실제로 소켓에 실어 본 S1F3

시뮬레이터의 passive 리스너에 소켓을 직접 열고, 호스트 역할로 Select.req를 먼저 보낸 다음 S1F3을 보냈다. 아래 hex는 장비 쪽에서 RX로 기록된 것이다.

TX Select.req  00 00 00 0A 00 0B 00 00 00 01 00 00 03 01
RX Select.rsp  00 00 00 0A 00 0B 00 00 00 02 00 00 03 01

TX S1F3 W      00 00 00 18 00 0B 81 03 00 00 00 00 03 02
               01 02 B1 04 00 00 00 65 B1 04 00 00 00 66
RX             (1000 ms 안에 아무것도 없음)

읽어 보면 이렇다.

  • 00 00 00 18 — E37의 4바이트 길이 접두어. 24다. 헤더 10 + 본문 14.
  • 00 0B — SessionID 11.
  • 81 — Header Byte 2. 최상위 비트가 W-bit, 나머지 7비트가 Stream. 0x81은 W-bit 1에 Stream 1이다.
  • 03 — Function 3. 합쳐서 S1F3, 응답을 요구하는 상태 변수 요청.
  • 00 00 — PType 0, SType 0. SType 0이 이 메시지를 데이터 메시지로 만든다.
  • 00 00 03 02 — SystemBytes.
  • 01 02 — List, 원소 2개.
  • B1 04 00 00 00 65 — U4, 길이 4바이트, 값 101. SVID 101이다.
  • B1 04 00 00 00 66 — U4, 값 102.

응답이 없는 건 이 리스너가 데이터 메시지에 답하지 않기 때문이다. 여기 S1F4는 없다. 내가 확인하려던 건 장비의 응답이 아니라 바이트가 그대로 도착하는지였고, 장비 쪽 RX hex는 내가 보낸 것과 한 바이트도 다르지 않았다.

아이템 헤더 해석 자체는 캡처가 증명해 주지 않는다. 이 리스너는 본문을 파싱하지 않는다. 위 해석은 E5를 근거로 한 것이고, 그렇게 밝혀 둔다.

ASCII 아이템과 빈 List

같은 연결에서 이어 보낸 S2F41이다.

TX S2F41 W     00 00 00 15 00 0B 82 29 00 00 00 00 03 03
               01 02 41 05 53 54 41 52 54 01 00

82는 W-bit에 Stream 2, 29는 Function 41이다. 본문은 01 02로 시작하니 원소 두 개짜리 List. 첫 원소가 41 05 53 54 41 52 54 — ASCII, 길이 5, START. 둘째가 01 00, 원소 0개짜리 List, 즉 파라미터 없음이다. 빈 List도 헤더 바이트와 길이 바이트를 다 갖춰야 한다. 01 00 두 바이트를 통째로 빼먹는 인코더를 본 적이 있는데, 그러면 뒤따르는 모든 게 한 칸씩 밀린다.

전체 길이 00 00 00 15는 21이다. 헤더 10 + 본문 11. 본문 11은 List 헤더 2 + ASCII 7 + 빈 List 2다. 바깥 길이 접두어는 안쪽 아이템 길이의 합과 반드시 맞아야 한다. 맞추는 건 순전히 인코더 책임이다.

길이 접두어를 1만 줄여 보면

같은 S1F3을 그대로 두고 앞의 4바이트만 00 00 00 17, 즉 23으로 바꿔 보냈다. 나머지 바이트는 손대지 않았다.

TX S1F3 W (길이 -1)
   00 00 00 17 00 0B 81 03 00 00 00 00 04 02
   01 02 B1 04 00 00 00 65 B1 04 00 00 00 66

장비 RX 기록
   00 00 00 17 00 0B 81 03 00 00 00 00 04 02
   01 02 B1 04 00 00 00 65 B1 04 00 00 00

마지막 66이 없다. 장비는 접두어가 시킨 대로 23바이트에서 잘랐고, 남은 한 바이트는 수신 버퍼에 그대로 남았다. TCP는 정상이다. 아무 에러도 나지 않았다.

문제는 그다음이다. 같은 연결에서 멀쩡한 S2F41을 보내고, 이어서 Linktest.req까지 보냈다.

TX S2F41 W (길이 정상)  → RX 기록 없음
TX Linktest.req         → 응답 없음

장비 로그에 잘린 S1F3 하나만 남고 뒤의 두 개는 아예 나타나지 않는다. 남아 있던 66이 다음 메시지의 첫 바이트가 되면서, 파서가 읽은 길이는 66 00 00 00, 10진으로 17억이다. 그만큼이 모일 때까지 기다리기 시작하고, 그 뒤로 이 연결에 들어오는 건 전부 그 안으로 빨려 들어간다. 대조군으로 길이만 바르게 고쳐 같은 순서를 다시 보내면 Linktest.rsp가 정상으로 돌아온다.

여기서 배울 건 증상의 위치다. 로그에 남는 건 다음 메시지가 실패했다는 사실이고, 틀린 건 그 앞 메시지의 길이다. Linktest가 죽었으니 T6부터 의심하게 되고, 실제 원인은 몇 초 전에 인코더가 계산한 숫자 하나다.

이 리스너는 T8을 구현하지 않는다

영원히 조용해지는 건 E37이 시킨 동작이 아니다. E37은 T8, network intercharacter timeout을 정의한다. 메시지 중간에서 다음 바이트가 오지 않을 때 얼마나 기다릴지를 정하는 값이고, 정확히 이런 상황을 끊으라고 있는 것이다. 이 시뮬레이터는 T8을 구현하지 않아서 무한정 기다렸다. 실제 장비라면 T8이 만료되고 연결을 끊는 쪽이 정상이다.

호스트 입장에서 차이는 크지 않다. 어느 쪽이든 그 연결은 끝났고, 다른 점은 알게 되기까지 걸리는 시간뿐이다.

인코더에 붙여 둘 것

  • 프레임을 조립한 뒤 길이 접두어 == 전체 길이 - 4를 assert로 확인한다. 한 줄이고, 위 증상을 통째로 막는다.
  • 본문 길이를 아이템 트리를 순회해 다시 계산해 보고 접두어와 비교한다. 두 값이 다르면 보내지 않는다.
  • List의 길이 바이트에는 원소 개수를 쓴다. 바이트 수가 아니다.
  • 디코더에서 아이템을 다 읽고도 본문이 남으면 버리지 말고 에러로 처리한다. 남은 바이트는 인코더가 틀렸다는 신호다.
  • 헤더 바이트를 로그에 남길 때 B1처럼 원본 hex를 같이 남긴다. U4로만 찍어 두면 길이 바이트 수가 몇 개였는지 사라진다.

캡처는 전부 SECS/GEM 시뮬레이터의 passive 리스너에 실제 소켓을 열어 만들었다. 직접 재현해 보려면 자기 호스트 스택으로 붙여서 본문을 인코딩해 보내고, 장비 쪽 RX hex가 보낸 것과 같은지만 비교하면 된다.

길이 접두어 자체가 어떻게 메시지 경계를 정하는지는 recv() 한 번에 HSMS 메시지 두 개가 오는 이유에 따로 썼고, Header Byte 2에 W-bit와 Stream이 같이 들어가서 생기는 문제는 여기에 있다.