나래온 툴 내부 구조 – 5. SATA 명령어 개요

필요성

NVMe 장치가 널리 퍼지긴 했지만, 여전히 SATA 장치는 저장장치의 대다수를 이루고 있다. SSD의 상당수가 SATA 인터페이스로 PC와 연결되며, HDD 역시 SATA로 연결된다. 따라서 이들의 정보를 가져오는 데에 이들의 언어인 ACS(ATA Command Set)는 필수적이다.

나래온 툴 역시 모든 구현의 기준은 SATA이며, 다른 장치에서 나온 결과는 SATA 형식으로 변환을 거치게 하는 식으로 동작하고 있다. 리팩토링 당시에 SATA의 특징을 많이 지우긴 했지만, 그래도 여전히 SATA의 흔적은 여기저기 남아있다.

이번 편에서는 SATA 명령의 개요에 대해서만 알아보도록 할 것이다. 명령어 상세는 다음 편에 다룰텐데, 이 내용을 다 포함하면 내용이 너무 복잡해지기 때문이다.

정확하게 말하면 ATA는 SATA와 다르고, SATA와 ACS는 다르지만 여기서는 편의상 모두에게 익숙한 SATA 명령어라고 표현하겠다.

ioctl code

지난 시간에 말했던 대로, 장치에 직접 명령을 내리려면 드라이버에서 passthrough ioctl code를 제공해줘야 한다. ATA의 경우는 아래와 같은 code들이 제공된다.

이 둘의 차이는, 전자는 Buffered I/O이고 후자는 Direct I/O라는 것이다. Buffered I/O의 경우 시스템 공간에 동일한 크기의 버퍼를 할당해 복사해서 처리하는 방법이고, Direct I/O는 유저 버퍼를 잠그고 그 공간을 직접 사용하는 방식이다. 인텔의 일부 드라이버에서 Buffered I/O에 문제가 생긴 경우가 있었으므로, 여기서는 Direct I/O를 권장한다.

입력

입력은 별 일이 없다면, passthrough 구조체 바로 뒤에 데이터 버퍼가 있는 식으로 메모리 상에 배치를 할 것이다. 즉 다음과 같은 구조체를 두고 쓰면 편하다. Parameter 상에 ATA 명령어와 관련한 온갖 파라미터가 들어가고, Buffer에는 데이터가 들어가는 식이다. 이렇게 하면 DeviceIoControl API에 포인터를 넣었을 때 문제가 생기지 않고, 데이터 버퍼를 관리하기도 상당히 편하다.

[snippet slug=ata_with_buffer lang=pascal]

그 다음으로 MSDN에 나온 입력 구조체를 보도록 하자.

두 ioctl의 입력 버퍼 (출처: MSDN)

두 입력 구조체는 상당히 유사하다. 그런데 딱 한 군데 차이가 있는 곳이 있다. 바로 버퍼를 지정하는 곳이다. Buffered 버전의 경우 DataBufferOffset을 요구하는데, 입력의 첫머리에서 데이터 버퍼까지의 거리를 나타내므로 sizeof(ATA_PASS_THROUGH_EX)로 가능하다. 그리고 Direct 버전의 경우 DataBuffer에 해당 데이터 버퍼의 포인터를 설정하면 된다.

PreviousTaskFile과 CurrentTaskFile 또한 짚고 넘어가야 할 부분인데, 처음 보면 이런 이름이 붙은 이유를 잘 모를 것이다. 여기서 Task File이란 우리가 윈도우 파일 시스템에서 부르는 파일이 아니라, ATA에서 말하는 Task File Register의 Task File이다. 여기에 대해서 가장 깔끔하게 나와있는 건 ATAPI-7 V1인데, 그 문서의 Table 9를 보면 각각이 하는 일을 자세히 알 수 있다. 하지만 우리가 내리는 명령들은 결국 거기에 써있는 목적들과는 다른 디스크 정보나 상태 관련 명령들이다. 따라서 내용은 무시하고 표준에 나와있는 그대로 써서 보내기만 하면 된다. 몇 번 index가 표준(ACS-3 기준)에 나와있는 표의 어떤 것에 대응되는 지는 다음 목록을 참고하라.

  • FEATURE: 0번
  • COUNT: 1번
  • LBA(low): 2번
  • LBA(mid): 3번
  • LBA(hi): 4번
  • DEVICE: 5번
  • COMMAND: 6번
  • 항상 공백: 7번

일반적인 명령은 CurrentTaskFile만 설정해도 충분하고, PreviousTaskFile은 비워두어도 상관 없다. 그러나 48bit 명령인 경우, PreviousTaskFile이 Hi 부분 24bit를 담당한다. MSDN에는 설명이 없고, Previous라는 이름 때문에 Lo로 착각할 수 있으므로 주의하자. 마찬가지로 LBA는 2, 3, 4번 순서대로 Lo, Mid, Hi를 8bit씩 채워주어야 한다.

이외에 설정할 값은 다음과 같다.

  • Length: Parameter 구조체의 size이다. ioctl에 따라 sizeof(ATA_PASS_THROUGH_EX) 혹은 sizeof(ATA_PASS_THROUGH_DIRECT)이 된다.
  • AtaFlags: 다음 편에 다룰 예정인 명령어의 Flag에 따라 지정하면 된다.
  • PathId, TargetId, Lun: ATA에서는 일반적으로 설정할 일이 없다.
  • ReservedAsUchar, ReservedAsUlong: 말 그대로 설정할 일이 없다.
  • DataTransferLength: 데이터 버퍼의 사이즈이다.
  • TimeOutValue: 초 단위로 타임 아웃을 설정한다. 어차피 이 기한을 넘는 경우는 장치가 죽은 경우이고, 그러면 프로그램은 정지하니 적당한 값으로 넣으면 된다. 나는 30으로 넣었다.

이렇게 한 뒤 입출력 버퍼를 동일하게 주고 DeviceIoControl 명령을 내리면, 출력이 돌아오게 된다.

출력

처음 써보면 보통 에러가 난다. DeviceIoControl에서 false가 return 되겠지만, 그동안 디버거 단위의 오류에 익숙해졌던 우리는 상당히 당황하게 된다. 소스 진행에는 문제가 없는데 아무 일도 일어나지 않은 것이다. 당황하지 말고 GetLastError API를 사용해보면 오류 코드가 나온다. 이 오류 코드를 System Error Codes에서 찾아보면 된다. 대부분은 1(ERROR_INVALID_FUNCTION) 아니면 87(ERROR_INVALID_PARAMETER)일 것인데, 어차피 모든 입력 값을 하나하나 검토해 보는 수밖에 없다. 따라서 오류에서 얻을 수 있는 정보가 별로 없더라도 좌절하지 말라.

다음으로 출력 형식에 대해서 생각해보자. 전체적으로 이 표준에서는 little endian을 사용한다. 따라서 숫자의 경우는 x86에서는 따로 처리해야 하는 문제가 없다. 하지만 문제는 string인데, 이상한 byte order를 따르기 때문이다. 우리가 평소 char*로 저장하던 것과 달리, word 단위로 두 바이트 순서를 바꾸는 방식이다(1번, 0번, 3번, 2번, …). 다음의 그림을 보면 더 쉽게 이해할 수 있다.

좌측은 수정을 적용한 것, 바이트 순서대로 읽어오면 우측처럼 뒤집혀 나온다

이는 과거의 16bit integer 임베디드 시스템 중 일부가 저런 식으로 string을 저장했기 때문이라고 한다. 아마도 메모리에서 바로 읽어서 줄 수 있도록 만들기 위해 저런 기준을 적용한 것 같다. 사실 나도 만들면서 이유가 굉장히 궁금했는데, 인터넷에 찾아보면 historical reason이라고 하고 자세하게 나오지 않아 이번에 글 쓰는 김에 찾아보았다.