소개
이 페이지는 Xillybus의 Linux 프로그래밍 가이드를 기반으로 합니다. 아래 주제들에 대해 더 포괄적인 내용이 필요하면 이 가이드를 참조하는 것이 좋습니다. Microsoft Windows용 유사한 가이드도 있습니다.
아직 "Hello, World" 테스트를 해 보지 않았다면 먼저 그 테스트를 해 볼 것을 권장합니다.
Xillybus IP 코어(IP core)와의 통신은 호스트의 디바이스 파일을 통해 이루어집니다. 이 디바이스 파일들은 일반 파일처럼 접근됩니다. 다만 일반 파일과 달리 디바이스 파일은 디스크에 저장된 데이터를 나타내지 않습니다. 디바이스 파일에 읽거나 쓰면 그 대신 I/O 연산이 수행됩니다.
따라서 사실상 모든 프로그래밍 언어로 Xillybus의 디바이스 파일에 접근할 수 있습니다. Linux의 명령줄 유틸리티를 사용하는 것도 가능합니다. 그렇다면 이 주제를 왜 논의해야 하는지 궁금할 수 있습니다. 파일을 올바르게 읽고 쓰는 방법을 알고 있다면 Xillybus를 다룰 줄도 아는 것이기 때문입니다.
사실, API에 대한 자세한 지식 없이도 일반 파일에 접근하는 프로그램을 작성할 수 있습니다. 그러나 I/O 작업에는 이것만으로 충분하지 않습니다. 하드웨어와의 상호 작용은 일반 파일에서는 거의 발생하지 않는 상황을 만들어 냅니다. 프로그래머로서 이런 상황을 API를 이용해 처리하는 방법을 알아야 합니다.
따라서 이 페이지의 대부분은 일반 파일 접근에도 적용되는 주제에 할애되어 있습니다. 디바이스 파일은 API를 사용할 때 실수에 대해 관대하지 않다는 점이 차이입니다.
API에 대한 이해 부족은 주로 두 가지 종류의 혼란을 초래할 수 있습니다.
- I/O 연산의 데이터 양이 예상과 다를 수 있습니다(대개 예상보다 적습니다).
- FPGA와의 통신이 예상보다 늦게 일어날 수 있습니다.
프로그래밍 언어와 운영 체제
Linux에서는 파일에 접근할 수 있는 모든 도구나 프로그래밍 언어가 Xillybus와 동작합니다.
Windows를 사용할 때는 C, C++, C#, Python, Perl, 그리고 Cygwin과 함께 제공되는 모든 것이 문제없이 동작합니다. 다만 일부 도구(예: MATLAB)는 Xillybus의 디바이스 파일과 동작하는 것을 거부할 수 있습니다. 이런 도구는 디바이스 파일이 일반 파일이 아니라는 것을 감지하고 오류로 간주합니다. 이런 상황에는 저수준 I/O(low-level I/O)를 위해 만들어진 확장 기능을 사용하는 해결 방법이 주로 쓰입니다.
아래 논의는 C 언어를 기반으로 하지만, 다루는 주제는 모든 프로그래밍 언어에 적용됩니다.
예제 코드
Xillybus 웹 사이트에 내려받을 수 있는 코딩 예제들이 있습니다. 이 예제들은 C 언어로 작성되어 있으며, 저수준 API를 올바르게 사용하는 방법을 보여 줍니다.
예제를 내려받으려면 데모 번들(demo bundle)을 내려받았던 웹 페이지로 가십시오. 즉, Xillybus용 페이지 또는 XillyUSB용 페이지로 가면 됩니다.
Linux를 사용하고 있다면 Linux 드라이버를 내려받으십시오. 예제 코드는 같은 .tar.gz 파일에 포함되어 있습니다.
Windows를 사용하고 있다면 Windows용 Xillybus 패키지를 내려받으십시오.
어느 쪽이든 예제 코드는 demoapps/ 하위 디렉터리에 있습니다. 이 코드에서는 각 I/O 연산에서 읽거나 쓰는 데이터 양이 작다는 점에 유의하십시오. 작은 버퍼(128바이트)를 할당하기 때문입니다. 이는 단지 코드를 더 단순하게 만들기 위한 것입니다. 실제 애플리케이션에서는 더 큰 버퍼를 권장합니다(대개 32KB 정도가 좋은 선택입니다).
Linux와 Windows의 차이는 작지만 중요합니다. 특히 다음과 같습니다.
- 함수 이름이 조금 다릅니다. 예를 들어 _open() 대신 open()입니다.
- Windows에서는 파일을 열 때 _O_BINARY를 사용해야 합니다.
그렇지만 예제 코드 뒤에 있는 원리는 완전히 동일합니다.
버퍼링된 파일 I/O
프로그래밍 언어는 대개 파일 접근을 위한 두 개의 별도 API를 제공합니다. 하나는 고수준 API이고, 다른 하나는 저수준 API입니다. 고수준 API가 더 널리 사용되는데, 작업하기가 더 쉽기 때문입니다.
예를 들어 C 언어에서 고수준 API는 fopen(), fread(), fwrite(), fprintf(), fclose() 등으로 구성됩니다. 저수준 API는 open(), read(), write(), close() 등으로 구성됩니다. 이 두 API의 차이는 단순히 함수 이름만 다른 것이 아닙니다. 고수준 API는 사용자 공간(user-space) RAM 버퍼를 제공합니다. 이 버퍼는 C 런타임 라이브러리가 구현합니다. 이 버퍼를 커널 안의 드라이버가 제어하는 DMA 버퍼와 혼동하지 마십시오.
가장 중요한 차이는 fwrite()의 동작입니다. 이 함수를 호출한 결과로 데이터가 사용자 공간 버퍼에 저장될 수 있습니다. FPGA로의 전송은 나중으로 지연될 수 있습니다. 실제로 데이터는 파일이 닫힐 때까지 fwrite()의 버퍼에 무기한 남아 있을 수 있습니다. Xillybus에서는 데이터가 파일에 기록되었는데도 아무 일도 일어나지 않아 버그처럼 보일 수 있습니다.
따라서 저수준(비버퍼링) API를 사용하는 것이 좋습니다. 대부분의 프로그래밍 언어가 고수준 API를 권장하지만, 저수준 API를 사용할 수 있는 경우가 대개 있습니다. 도구나 프로그래밍 언어가 저수준 API를 지원하지 않는다면, 사용 가능한 API를 대신 사용하십시오. 이 경우 I/O가 언제 수행되는지 제어할 수 없다는 점을 기억하는 것이 중요합니다. 그렇지만 많은 애플리케이션, 예를 들어 데이터 수집(data acquisition)에서는 이것만으로도 충분합니다.
다시 말하지만, 버퍼링된 I/O를 Xillybus의 버퍼와 혼동하지 마십시오. 무엇보다 Xillybus의 버퍼 때문에 데이터가 무한정 멈춰 있는 일은 결코 없습니다. 이 내용은 아래에서 0바이트 write()와 관련해 자세히 설명합니다.
디바이스 파일에서 읽기: 기본 사항
디바이스 파일에서 읽기 위한 예제 코드는 streamread.c입니다. 이 프로그램은 demoapps/ 디렉터리에서 찾을 수 있습니다. 그러나 아래에서 보여 줄 코드는 같은 디렉터리에 있는 memread.c라는 다른 프로그램에서 가져온 것입니다. 이 프로그램은 다른 목적을 위해 만들어졌지만, allread()라는 함수를 담고 있습니다. 이 함수로 몇 가지 주제를 설명하는 것이 더 편리합니다.
파일이 다음과 같이 열렸다고 가정해 보겠습니다.
int fd, len;
char *buf;
fd = open("/dev/xillybus_read_32", O_RDONLY);
이제 파일에서 @len 바이트를 버퍼로 읽고 싶다고 합시다. 그러나 중간 결과는 허용되지 않습니다. 항상 요청한 양의 데이터를 읽는 함수가 필요합니다.
allread()를 다음과 같이 사용했을 때 바로 그런 일을 합니다.
allread(fd, buf, len);
이 함수의 정의는 다음과 같습니다.
void allread(int fd, unsigned char *buf, int len) {
int received = 0;
int rc;
while (received < len) {
rc = read(fd, buf + received, len - received);
if ((rc < 0) && (errno == EINTR))
continue;
if (rc < 0) {
perror("allread() failed to read");
exit(1);
}
if (rc == 0) {
fprintf(stderr, "Reached read EOF\n");
exit(1);
}
received += rc;
}
}
이 함수는 항상 요청한 바이트 수를 읽는다는 점에 유의하십시오. 이것이 불가능하면 함수가 프로그램을 종료시킵니다. 정상적인 사용 시나리오에는 너무 과격할 수 있습니다. allread()는 저수준 API로 파일에 접근하는 간단한 예로 취급하십시오.
이 함수를 설명해 보겠습니다. 첫 번째로 흥미로운 부분은 다음 코드입니다.
rc = read(fd, buf + received, len - received);
첫 번째 루프 동안 @received는 0입니다. 따라서 이 줄은 다음 코드와 동일합니다.
rc = read(fd, buf, len);
read()는 파일 디스크립터(@fd)에서 @len 바이트를 읽고 그 데이터를 버퍼(@buf)에 저장하려고 시도합니다.
Xillybus 디바이스 파일의 경우 read()는 요청한 데이터 양(@len 바이트)을 사용할 수 없으면 최대 10ms 동안 기다립니다. 그 짧은 시간이 지나면 함수는 필요한 양보다 적은 데이터(그래도 최소 1바이트)를 가지고 반환합니다. 데이터가 전혀 없으면 read()는 FPGA에서 데이터가 도착할 때까지 무기한 대기합니다(예외도 있습니다. 이에 대해서는 아래에서 더 설명합니다). 이 동작은 Xillybus 드라이버에 특화된 것이지만 표준 API를 준수합니다.
read()가 무언가를 읽을 수 있었다면 @rc는 읽은 바이트 수와 같습니다. 즉 @rc는 양수이고, 루프 안의 모든 if 문은 건너뜁니다. 그래서 다음 코드가 실행됩니다.
received += rc;
따라서 @received는 항상 지금까지 읽은 총 바이트 수를 담고 있습니다. @rc가 @len(요청한 바이트 수)보다 작은 것은 전적으로 정상적이고 유효하다는 점에 유의하십시오.
while 루프는 @received(지금까지 읽은 총 바이트 수)가 @len에 도달할 때까지 계속됩니다. 이는 while 문에 반영되어 있습니다.
while (received < len) { ... }
따라서 필요하다면 추가로 데이터를 읽습니다.
rc = read(fd, buf + received, len - received);
이번에는 버퍼의 시작 위치가 @received만큼 이동합니다. 요청하는 바이트 수도 같은 수만큼 줄어듭니다. 이 조정은 단지 데이터를 읽으려는 반복적인 시도를 반영할 뿐입니다.
read()가 아무것도 읽지 못하는 경우
지금까지는 read()가 데이터를 읽을 수 있을 때 어떤 일이 일어나는지 살펴보았습니다. 그러나 read()가 아무것도 읽지 못하는 상황도 세 가지 있습니다. 각 상황은 각자의 if 문으로 처리됩니다.
POSIX 시그널
프로그램을 중지하려고 CTRL-C를 누르면 운영 체제가 그 프로세스로 POSIX 시그널을 보냅니다. 이것이 프로그램을 종료시키는 메커니즘입니다. 같은 목적으로 "kill" 명령을 사용할 때도 같은 일이 일어납니다. 그런데 대부분의 상황에서 무시해야 하는 다른 유형의 시그널도 많이 있습니다.
그렇다면 read() 함수 호출 도중에 프로세스가 시그널을 받으면 어떻게 될까요? Linux 규칙에 따르면 read()는 즉시 제어권을 메인 프로그램으로 돌려주어야 합니다. 그 전에 read()가 데이터를 읽을 수 있었다면 특별한 일은 일어나지 않습니다. @rc는 바이트 수를 담고 있으며, 시그널을 받았다는 표시는 없습니다.
그러나 새 데이터가 도착하지 않았다면 @rc는 음수가 되고 @errno는 EINTR이 됩니다. 이 상황을 처리하는 표준 방법은 코드에 표시된 것처럼 아무 일도 없었다는 듯이 동작하고 다시 시도하는 것입니다.
if ((rc < 0) && (errno == EINTR))
continue;
이것은 시그널이 무시된다는 뜻은 아닙니다. 예를 들어 사용자가 CTRL-C를 눌러 시그널이 발생했다면 프로그램은 평소처럼 종료됩니다. 그 역할을 하는 또 다른 메커니즘이 있습니다. 이 if 문의 목적은 특별한 일을 일으키지 않으려는 시그널을 처리하는 것입니다. continue 문은 프로세스가 이런 종류의 시그널을 받아도 이상한 일이 일어나지 않도록 보장합니다.
예를 들어 프로세스가 CTRL-Z로 중지되었다가 실행이 재개되면, 프로그램이 계속 실행되도록 이 if 문이 필요합니다. 그 밖에도 사람의 조작 없이 도착할 수 있는 여러 시그널이 있습니다.
실제 오류
데이터를 읽는 도중에 무언가 잘못되는 일이 당연히 생길 수 있습니다. 이 경우 @rc는 음수이고 @errno 값은 EINTR이 아닙니다. 예제 코드는 이 오류를 보고하고 프로그램을 종료합니다.
if (rc < 0) {
perror("allread() failed to read");
exit(1);
}
EOF
파일 끝에 도달해서 read()가 더 이상 데이터를 공급할 수 없다면 이 함수는 값 0을 반환합니다. 일반 파일에 대해서는 물론 그렇습니다. Xillybus도 데이터 스트림이 끝났음을 선언하는 기능이 있으며, 동작은 동일합니다.
관련 코드는 다음과 같습니다.
if (rc == 0) {
fprintf(stderr, "Reached read EOF\n");
exit(1);
}
이 경우도 오류로 처리되어 프로그램을 종료시킵니다. 요청한 데이터 양(@len 바이트)을 다 읽기 전에 EOF에 도달했다는 뜻이기 때문입니다. 이 if 문은 @received가 @len보다 작을 때만 도달할 수 있습니다.
allread()의 기본 개념은 항상 필요한 양의 데이터를 읽는다는 것이었습니다. 이것이 불가능하면 이 함수가 컴퓨터 프로그램을 중지시킵니다.
다른 프로그래밍 언어에의 적용
위의 예제 코드는 C 언어로 작성되었지만, 프로그래밍 언어와 무관하게 적용되는 몇 가지 중요한 점을 보여 줍니다.
- read()는 요청한 양보다 적은 데이터를 가지고 반환할 수 있습니다. 이것은 버퍼링된 I/O(예: fread())에서도 일어날 수 있습니다. 다만 버퍼링된 I/O에서는 오류가 있거나 EOF에 도달한 경우에만 그렇습니다. 반면 read()에서는 이것이 정상이며, 특별한 일이 일어났다는 뜻이 아닙니다.
- 프로그램은 POSIX 시그널을 올바르게 처리해야 합니다.
- read()는 모든 데이터를 읽고 파일 끝(EOF)에 도달한 후에는 값 0을 가지고 반환합니다.
디바이스 파일에 쓰기
파일에 쓰기 위한 저수준 API는 파일에서 읽기 위한 API와 거의 같습니다. 이를 보여 주기 위해, streamwrite.c에 들어 있는 allwrite()라는 함수를 소개합니다.
void allwrite(int fd, unsigned char *buf, int len) {
int sent = 0;
int rc;
while (sent < len) {
rc = write(fd, buf + sent, len - sent);
if ((rc < 0) && (errno == EINTR))
continue;
if (rc < 0) {
perror("allwrite() failed to write");
exit(1);
}
if (rc == 0) {
fprintf(stderr, "Reached write EOF (?!)\n");
exit(1);
}
sent += rc;
}
}
이 함수를 위에서 본 allread()와 비교해 보면 차이는 세 가지뿐입니다.
- read() 대신 write()를 사용합니다. 그러나 이 함수들은 정확히 같은 방식으로 사용됩니다.
- 변수 이름 @received가 @sent로 바뀌었습니다. 다만 그 차이는 변수 이름뿐입니다. 이 변수의 의미와 사용법은 완전히 같습니다.
- 텍스트 출력이 조정되었습니다. 이전에 "read"라고 쓴 곳에 "write"라고 씁니다.
그래서 원칙적으로 쓰기와 읽기 사이에는 차이가 없습니다.
그렇지만 @rc는 절대 0이 되어서는 안 된다는 점에 유의하십시오. 파일에 쓸 때 EOF라는 것은 의미가 없기 때문입니다. POSIX 표준에 따르면 write()가 0바이트를 쓰도록 요청받았을 때만 @rc가 0일 수 있습니다. 그런데 이 while 루프에서는 그런 경우가 없습니다.
요약하면, allwrite()는 요청한 바이트 수를 항상 씁니다. 그 외의 선택은 프로세스를 종료하는 것뿐입니다. 다시 말해, 디바이스 파일이 다음과 같은 방식으로 열렸다고 가정해 보겠습니다.
int fd, len;
char *buf;
fd = open("/dev/xillybus_write_32", O_WRONLY);
@buf에서 @len 바이트를 쓰는 작업은 다음과 같이 수행됩니다.
allwrite(fd, buf, len);
위에서 allread()에 대해 말한 모든 것은 allwrite()에도 동일하게 적용됩니다. 다른 프로그래밍 언어에의 적용도 여기에 포함됩니다.
0바이트 write()
write()를 0바이트로 호출하는 것은 허용됩니다. 표준 API는 그 특정한 경우에 무엇이 일어날지 말하지 않습니다. 그러나 분명히 아무 데이터도 쓰이지 않습니다.
이런 함수 호출은 Xillybus 디바이스 파일에서 특별한 의미를 가집니다. 0바이트 쓰기는 플러시(flush)를 요청하는 것을 의미합니다. 이 의미를 이해하려면 먼저 디바이스 파일에 데이터를 쓸 때 어떤 일이 일어나는지 살펴보겠습니다.
디바이스 파일이 비동기 스트림(asynchronous stream)이라고 가정해 보겠습니다. 이 용어는 다른 페이지에 간략히 설명되어 있으며, 문서에는 더 자세히 설명되어 있습니다.
디바이스 파일에 데이터를 쓰면(write()로) Xillybus 드라이버는 이 데이터를 RAM 버퍼에 저장합니다. 이 데이터 중 일부나 전체가 즉시 FPGA로 보내질 수도 있습니다. 그러나 일반적으로 일부 데이터가 버퍼에 남을 수 있으며, 함수 호출을 수행한 프로그램은 그와 무관하게 계속 실행됩니다. 이 메커니즘의 목적은 성능을 향상시키는 것이며, 특히 write() 함수 호출이 많을 때 효과적입니다.
그렇다면 드라이버의 RAM 버퍼에 있는 데이터는 언제 FPGA로 보내질까요? 네 가지 상황이 있습니다.
- RAM 버퍼가 가득 찰 때.
- 디바이스 파일이 닫힐 때.
- 10ms의 시간이 경과할 때(자동 플러시).
- 0바이트 write()가 수행될 때.
따라서 데이터가 드라이버 버퍼에 너무 오래 머물러 있는 경우는 없습니다. 데이터는 항상 10ms 이내에 FPGA로 보내지기 때문입니다. 그러나 일부 애플리케이션에서는 이 지연조차 허용되지 않을 수 있습니다. 그럴 때는 0바이트 write()를 사용해 남아 있는 데이터를 즉시 보내도록 요청할 수 있습니다.
C 언어에서는 다음과 같이 수행합니다.
write(fd, NULL, 0);
버퍼의 주소가 NULL이라는 점에 주목하십시오. 쓸 바이트 수가 0이므로 문제없습니다. 하지만 이 함수 호출은 요청이 반드시 성공한다는 것을 보장하지는 않습니다. 성공할 가능성은 매우 높지만, 올바른 방법은 다음과 같습니다.
while (1) {
rc = write(fd, NULL, 0);
if ((rc < 0) && (errno == EINTR))
continue; // Interrupted. Try again.
if (rc < 0) {
perror("flushing failed");
break;
}
break; // Flush successful
}
지금까지의 설명은 비동기 스트림에 대한 것입니다. 디바이스 파일이 동기 스트림(synchronous stream)이라면 write() 함수 호출의 결과로 데이터가 항상 즉시 FPGA로 보내집니다. 또한 write()는 데이터가 FPGA에 도달할 때까지 기다렸다가 반환됩니다(0바이트 write()는 그렇게 하지 않습니다).
따라서 0바이트 write()는 비동기 스트림에서만 의미가 있습니다. 이 기능은 필요한 경우가 아니라면 사용하지 않는 것이 좋습니다. FPGA와의 통신 속도를 떨어뜨리기 때문입니다.
요약
앞서 언급했듯이, 위에 쓴 내용은 거의 모두 일반 파일에 접근할 때도 맞는 말입니다. Xillybus에 특화된 주제는 몇 가지만 있습니다.
FPGA와 통신할 때 일관된 동작을 보장하려면 이 지침을 따르는 것이 중요합니다. 이런 주제를 고려하지 않고 작성된 프로그램은 간헐적으로 실패할 가능성이 높습니다. 이런 실패는 FPGA나 드라이버 문제처럼 보이는 경우가 많습니다. 따라서 올바른 프로그래밍 기법을 사용하면 혼란과 불필요한 수고를 크게 줄일 수 있습니다.