CPU, GPU, 메모리를 다루면서 어찌보면 마법처럼 이루어져보이는 IO에 대해 공부해야겠다는 생각이 들었다.
특히 최근에 GPU 구조에 대해 공부하면서 어떻게 GPU가 DRAM에 있는 데이터를 CPU의 개입 없이 가져가는지 등 ( DMA라는 것은 알지만 DMA가 어떻게 동작하는지는 모른다 ) 언젠가는 IO에 대해 공부를 해야겠다고 생각하였다.
IO가 어떻게 작동하는지에 대해 알면 프로그래밍을 하면서 엄청난 도움이 될 것 같아 공부를 하였다.
이 글에서는 IO 시스템에 대해 알아보려고한다.
이 글을 번역하였다. 오역이나 발번역이 있을 수도 있다.
전반적인 이해
IO 장치들의 관리는 OS에서 가장 중요한 부분이다. IO에 도움을 주는 IO 하부시스템들은 항상 중요했고 변화를 거쳐왔다. ( 현대 컴퓨터에 있는 마이크, 키보드, 마우스, 디스크, USB 디바이스, 네트워크 연결, 오디오 IO 등등…. 수 많은 IO 장치들이 존재한다. )
IO 하부시스템들은 크게 두가지 트렌드로 나누어진다. 하나는 새롭게 개발된 장치들에도 쉽게 쉽게 적용할 수 있는 표준 인터페이스 유행, 다른 하나는 현존하는 표준 인터페이스들이 쉽게 적용되지 않는 새로운 유형의 장치들의 발전이 그것이다. ( 터치 스크린 같은 것이 기존에는 없던 IO 유형 중 하나이다. )
디바이스 드라이버는 특정 장치나 비슷한 유형의 장치들을 다루기 위해 운영체제에 적용될 수 있는 모듈이다.
IO 하드웨어
IO 디바이스들은 크게 저장 디바이스, 상호 작용 ( 커뮤니케이션 ), 유저 인터페이스 ( UI ) 등이 있다.
디바이스들 무선으로 혹은 유선으로 전달되는 시그널들을 통해서 컴퓨터와 소통한다.
디바이스들은 시리얼, 병렬 포트와 같은 포트들로 컴퓨터에 연결된다.
다양한 장치들을 연결하는 선들의 집합, 묶음을 버스라고 부른다.
버스들은 경합 ( Contention ) 문제를 해결하기 위해 버스를 통해 전달될 수 있는 여러 메세지들의 유형, 절차들에 대한 엄격한 프로토콜을 가지고 있다.
아래의 사진을 보면 현대 컴퓨터에서 볼 수 있는 4가지 유형의 버스들 중 3가지 유형을 볼 수 있다.
- PCI 버스는 빠르게 동작하고 높은 대역폭을 가진 장치들을 메모리 하부시스템, CPU에 연결한다. ( GPU를 생각하면 된다. )
- Expansion 버스는 한번에 하나의 문자를 전달하는 것과 같은 조금은 느리고 낮은 대역폭을 가진 장치를 연결한다.
- SCSI 버스는 많은 SCSI 장치들을 SCSI 컨트롤러와 연결시켜준다.
장치들과 소통하는 방법 중 하나는 각각의 포트와 연관된 IO 장치가 가지고 있는 레지스터를 이용하는 것이다. ( IO mapped IO ) 레지스터들은 1에서 4바이트 정도의 크리고 일반적으로 4가지 종류가 있다.
- 첫번째로는 data-in 레지스터는 장치로부터 입력을 얻기위해 호스트에 읽혀진다.
- 두번째로는 data-out 레지스터로 출력을 전송하기 위해서 호스트에 의해 쓰여 ( write )진다.
- 세번째로는 status ( 상태 ) 레지스터로 input, busy, error, transaction 완료 등에 대한 idle, ready와 같은 장치의 상태를 명시하기 위해 호스트에 의해 읽혀지는 비트들을 가지고 있다.
- 마지막으로 control 레지스터는 패리티 체크, 워드 길이, 전이중 이중 통신과 같은 장치의 설정을 바꾸거나, 커맨드들을 발행하기 위해서 호스트에 의해 쓰여 ( write )지는 비트들을 가지고 있다.
IO 데이터 전송을 위해 CPU는 직접 IO 장치들의 이 레지스터들에 값을 읽거나, 써서 IO 장치와 소통한다.
그럼 어떻게 CPU가 IO 장치의 레지스터들에 값을 쓸까?
두가지 방법이 있다. 하나는 IO ( 포트 ) 맵 IO, 다른 하나는 메모리 맵 IO 이다.
우선 IO 맵 IO의 경우 IO만을 위한 특별한 IO 명령어를 통해 이루어지는데 이 IO 명령어로 특정 IO 장치를 지정하고 그 IO 장치의 레지스터로 데이터를 보낸다.
반면 메모리 맵 IO ( memory-mapped IO = memory-mapped Port IO )의 경우 IO만을 위한 특별한 명령어가 없고, 일반적으로 사용하는 메모리 연산을 위한 명령어를 그대로 사용한다. 일반적인 메모리 명령어를 사용하여 IO 통신을 하는데 주소 버스에 만약 IO 장치의 레지스터, 로컬 메모리가 맵된 주소가 들어오면 해당 주소에 맞는 IO 장치에 시그널을 보내고 그럼 그 IO 장치는 디바이스 컨트롤러를 통해 버스의 데이터를 읽어가는 것이다.
- 이 경우 IO 장치의 레지스터, 저장공간이 CPU의 주소 공간 ( Logical 주소 공간 )에 맵되고 CPU가 그 메모리 주소 공간에 쓰거나, 읽어서 디바이스와 소통할 수 있다. ( Physical Memory를 사용하는 것이 아니라 가상의 주소 공간을 사용하는 것이다. )
- 메모리 맵 IO는 그래픽 카드와 같이 많은 양의 데이터를 전송해야하는 장치들에 적합하다.
- 메모리 맵 IO는 일반적인 레지스터들과 함께 사용될 수도 있고 그것들 대신 사용될 수도 있다.
- 메모리 맵 IO의 접근이 일반 DRAM을 접근할 때와 똑같기 때문에 CPU 구조상 간단하다. ( RISC가 목표로하는 것 )
- 메모리 맵 IO는 잠재적인 문제를 가지고 있는데, 만약 한 프로그램이 메모리 맵된 IO 장치에 의해 사용되고 있는 주소 공간에 직접적으로 쓰게 되면 문제가 발생한다.
- ( 메모리 맵 IO는 직접 메모리 접근 ( DMA )와는 다른 것이다. )
또한 데이터를 IO 장치로부터 읽어올 때도 큰 성능향상이 있다. 읽기 동작을 수행할 때 IO 장치의 레지스터 혹은 로컬 메모리가 프로세스의 가상 주소에 매핑되어 있다. 읽기를 수행하면 IO 장치의 데이터가 DRAM에 복사되고 ( 커널 모드 메모리 공간으로 복사된다 ) 이 복사된 데이터는 프로세스의 유저 모드 가상 주소의 테이블과 매핑되어 있기 때문에 바로 읽기를 수행할 수 있다.
반면 메모리 맵 IO를 사용하지 않는 경우 IO 장치로부터 DRAM에 복사된 데이터 ( 커널 메모리 공간에 존재 )는 유저 프로세스의 가상 주소 공간과 매핑되어 있지 않기 때문에 이 데이터를 다시 유저 모드 메모리 공간으로 복사를 해야한다. IO -> DRAM, DRAM의 커널 공간 -> DRAM의 유저 공간. 총 두 번의 복사가 발생한 것이다.
메모리 맵 IO에 대해 추가적으로 설명하자면 일단 메모리 맵 IO는 물리적 DRAM에 데이터가 있는 것이 아니다. IO의 매모리 맵된 데이터 자체는 주변 장치의 레지스터나 주변 장치의 메모리에 있다.
이를 완전히 이해하기 위해서는 CPU가 어떻게 메모리나 주변 장치들에게 데이터를 전송하는지 알아야한다. 위에서 본 것처럼 레지스터를 이용하는 방법 ( IO Mapped IO )이 있고 지금 설명할 메모리 맵 IO 주소를 활용하는 방법이 있다.
장치 ( 디바이스 컨트롤러든, 메모리 컨트롤러든 )가 시스템의 주소 버스와 연결되어 있고 주소 디코더가 주소 버스를 분석해서 해당 주소에 맞는 메모리 혹은 주변장치에게 시그널을 보내준다. ( 주소 디코더가 주소 버스의 주소를 읽고 해석하여서 그에 맞는 주변 장치 혹은 메모리에게 시그널을 발생시키는 개념이다. 만약 메모리에 시그너링 전송되면 메모리 컨트롤러가 메모리 버스에서 데이터를 읽어갈 것이고, 만약 주변 장치에게 시그널이 전송되면 주변 장치의 디바이스 컨트롤러가 데이트를 읽어갈 것이다. ( 디바이스 컨트롤러는 장치가 사용할 혹은 장치가 보유 중인 데이터를 버스로부터, 버스에 읽고, 쓰는 역할을 한다. ) )
( 시스템 주소 버스는 쉽게 생각하면 CPU가 메모리 동작을 수행할 목표가 되는 주소를 전송하는 버스이다. CPU가 시스템 주소 버스에 데이터를 쓸 주소를 설정하고, 데이터 버스에 쓸 데이터를 설정한 후 시그널을 보내면 메모리 컨트롤러가 해당 동작을 수행하는 방식이다. 이는 메모리 컨트롤러가 버스 데이터를 읽어갈 수도 있고, 디바이스 컨트롤러가 읽어갈 수도 있다. )
CPU가 어떤 주소를 보고 쓰기나 읽기 동작을 수행할 때 그 주소가 DRAM에 있는건지 메모리 맵 IO된 주변 장치의 레지스터를 매핑하는 주소인지 알 수가 없다. 그냥 시스템 주소 버스, 데이터 버스에 주소를 쓰고 데이터를 쓰는 것이다.
이게 메모리 맵 IO의 장점이다. CPU가 메모리 맵된 IO와 일반 DRAM에 데이터를 읽고 쓰는데 사용되는 명령어 코드가 완전히 똑같다.
여기서 OS가 하는 일은 밑에서 설명하겠지만 해당 데이터를 캐싱하거나 레지스터에 쓰지 말고 반드시 DRAM에 쓰라고 강제하는 것이다.
간단한 예를 들어보면 애플2의 스피커는 딸깍하는 클릭 소리는 0xC030 주소에 읽기나 쓰기 동작이 있을 때 난다. 주소 디코더가 주소 버스의 값이 정확히 0xC030 -> 1100000000110000인 것을 발견하면 스피커의 out 라인이 high로 변하고 스피커 회로는 소리를 발생시킨다. 또한 더 세련된 주변 장치들은 데이터 버스에 어떤 값이 쓰였는지에 따라 대응하고 ( CPU가 어떤 주소로 맵된 주변 장치의 컨트롤 레지스터에 쓰기 동작을 수행하면 해당 주변 장치가 반응한다. ), 혹은 주변 장치가 직접 데이터를 데이터 버스에 쓴다.
CPU가 주소 버스에 어떤 주소를 썼는데 그 주소를 주소 디코더로 디코딩해보니 어느 곳에도 속하지 않는 이상한 주소일 수도 있다. ( CPU 주소가 항상 유효한 주소를 보장하지는 않는다는 것이다. )
가장 중요한 것은 CPU가 디바이스 ( 주변 장치든, 디스크든 )나 메모리에 데이터를 직접 쓰고 읽는다는 것은 하드웨어적으로는 말이 안되는 개념이다. CPU ( 혹은 DMA ) 가 버스를 마스터링하고 버스에 데이터를 쓰고, 컨트롤 버스를 통해 시그널을 보내면, 그 시그널을 받은 것이 메모리든 ( 메모리 컨트롤러 ), IO든 ( 디바이스 컨트롤러 ) 시그널을 받은 후 버스로부터 데이터를 읽는 것이다. CPU가 직접적으로 주변 장치나 메모리에 데이터를 쓰거나 읽지 않는다.
CPU나 DMA 컨트롤러가 주소 버스, 컨트롤 버스에 주소, 명령어를 쓰면 메모리 컨트롤러나 디바이스 컨트롤러로 시그널이 전달되고 ( 주소 디코더가 누가한테 전달할지 결정 ) 메모리 컨트롤러나 디바이스 컨트롤러는 컨트롤 버스에 있는 명령어를 토대로 동작을 수행한다. 만약 컨트롤 버스에는 읽기 명령, 주소 버스에는 읽을 주소가 있으면 메모리 컨트롤러 ( 혹은 디바이스 컨트롤러 )는 CPU가 읽을 주소의 데이터들을 버스에 올려서 다시 전달해준다.
( 또한 데이터 버스는 CPU와 주변장치, 메모리간에 양방향 전다링 가능하지만, 주소 버스 같은 경우에는 CPU만 쓰니 단방향 통신이다. )
( 흔히”워드”라고 부르는 것은 CPU 명령어가 다루는 데이터 크기를 나타낸다. 즉 한번의 명령어로 처리할 수 있는 데이터의 사이즈를 말한다. 대부분의 경우(!) 이 워드의 사이즈는 CPU의 레지스터 사이즈와 같고, 데이터, 주소 버스의 너비와 같다. )
그리고 IO 통신이란 이렇게 버스를 통해 시그널을 보내고 디바이스 컨트롤러가 버스 데이터를 받는 동작들을 말한다.
그래서 어떤 코어든 DMA든 같은 메모리 버스를 공유하기 때문에 누군가가 버스를 마스터링하고 있으면 다른 코어나 DMA는 데이터를 읽거나 쓸 수 없다, 기다려야한다.
참조 - CPU가 메모리, IO 장치에 데이터를 전송하는 하드웨어 원리
조금 더 하드웨어적으로 깊게 설명하면 메모리 맵 IO는 CPU 단계에서 주소 버스를 포트 IO 라인트로 오버로딩을 하는데, 이를 통해서 메모리에 대한 쓰기 동작을 ( 위에서 말했든 메모리 맵 IO의 쓰기 동작은 메모리에 쓰는 명령어 코드와 완전히 똑같다. ) QPI 버스 라인으로 변환한다. 이는 CPU가 마더보드와의 상호작용을 통해 이루어진다. IO 맵 IO 방식과 달리 메모리와 똑같은 방식으로 데이터를 전송하니 CPU 하드웨어적으로 로직 처리가 간단해진다. 그러나 메모리 쓰기와 같이 메모리 버스를 활용하다보니 속도가 더 느릴 수 있다. ( 메모리 버스는 모든 코어들도 접근하고, DMA도 접근하다 보니 바쁘기 때문에. ) ( CPU가 버스를 통해 직접 디바이스와 통신하니 DMA가 관여하지도 않는다. )
반면 IO ( 포트 ) 맵 IO 방식은 메모리 접근과 IO 접근을 명령어 단계에서 완전히 분리되어 있고 주소 공간도 분리되어 있다. 프로세서의 칩셋에 별도의 신호 핀을 두어 I/O명령을 전달하는 방식으로 IO 접근을 위한 별도의 명령어를 사용해야하고 이를 해석하기 위한 별도의 하드웨어 장치가 필요하다. 과거 ( 16bit 시절 )에는 가상 주소 공간이 제한이 있어 IO와 주소 공간을 같이 쓰는 메모리 맵 IO 방식을 사용하기 부담이 컸지만 현재는 가상 주소 공간은 부족할 일이 거의 없으니 메모리 맵 IO를 많이 활용하는 추세이다.
또한 디바이스가 그 데이터를 내부적으로 저장하고 있을 수도 있고 안할 수도 있다. 스피커를 봐라. 스피커는 내부적으로 어떠한 데이터도 저장해두지 않는다. 그냥 주소 디코더가 스피커에 매핑된 주소가 주소 버스에 들어오면 주소 디코더가 스피커에게 시그널을 보내고 스피커는 그 시그널을 받고 소리를 발생시키는 것이다.
이렇게 CPU가 주소가 메모리 맵된 IO 주소인지 메모리 주소인지 알지 못하기 때문에 CPU가 캐싱을 하거나 연산 결과를 레지스터에 저장하는 등의 동작을 하면 문제가 될 수도 있다. 메모리 맵된 IO인 경우 IO 장치가 해당 메모리 명령어를 볼 수 있기 위해서는 반드시 메모리 버스를 통해야 IO 디바이스들이 볼 수 있는데, 만약 CPU가 그냥 자기 자신이 들고 있는 캐시에 데이터를 써버리면 IO 디바이스들은 이를 볼 수가 없다. 그래서 매핑된 페이지에 Non-Cachable 옵션을 붙여서 주소에 대한 쓰기 동작을 캐시 버스 혹은 레지스터에 쓰는 것을 막고 메모리 버스에 쓰게 강제 ( 실제로 메모리에 쓰는건 아니고 메모리 맵 IO로 IO 장치로 데이터가 전달된다. )할 수 있다.
그리고 메모리 맵 IO와 DMA는 아무런 관련이 없다. 메모리 맵 IO는 CPU가 메모리 버스 ( 주소 버스, 데이터 버스 )를 통해 메모리 ( DRAM )나 IO에 통신하는 방법 중 한 방법이고,
DMA는 주변 장치들 끼리 혹은 메모리 ( DRAM )와 주변 장치간의 데이터를 전달하거나 읽을 때 CPU를 대신하여 DMA가 버스에 데이터를 쓰는 것이다. DMA가 이러한 작업을 대신하기 때문에 그 동안 CPU는 다른 일을 할 수 있다.
시스템 주소 버스 vs 데이터 버스
메모리 맵 IO 참고1
메모리 맵 IO 참고2
메모리 맵 IO ( 메모리 맵 포트 IO )와 관련하여 메모리 맵 파일이라는 개념이 존재한다. 흔히 mmap()이라는 함수로 알려진 기능이다. 메모리 맵 IO와 메모리 맵 파일은 약간은 다른 개념이다.
메모리 맵 파일은 간단히 말하면 파일의 데이터들을 가상 주소로 페이징해서 페이지 테이블로 관리하는 것이다.
기본적으로는 파일들이 페이지 out되어 있고 읽으려고 하면 메모리로 페이지 in해서 사용한다. ( 필요할 때만 메모리로 읽어온다는 것이다! ) 또한 페이지 out될 때 페이지가 dirty한 경우 파일에 쓰기 동작을 수행한다.
그리고 페이지 in된 페이지 데이터는 메모리에서 페이지 캐시라는 특별한 영역에 저장된다. 이 페이지 캐시는 OS가 항상 다른 데이터를 할당하지 않고 메모리 맵 파일을 위해 비워두는 영역인데 disk로부터 페이징했던 페이지는 항상 이 곳에 저장된다.
그래서 메모리 맵 IO는 우선 디스크에서 이 페이지 캐시로 데이터를 복사하고 ( 아마 DMA가 할 것이다 ), 그럼 유저 프로세스는 이 페이지 캐시의 Physical 페이지를 자신의 가상 주소 페이지 테이블에 참조해 넣으면 된다. ( 여기서 중요한 것은 **유저 프로세스가 페이지 캐시에서 Physical 페이지를 자신의 유저 Physical 공간으로 복사해가는게 아니라 그냥 페이지 캐시의 Physical 페이지에 대한 참조를 자신의 페이지 테이블에 추가한다는 것이다. 복사가 아니다. ) ( 반면 read()와 같은 stream 파일 읽기는 디스크에서 우선 커널 메모리 영역의 버퍼로 한번 복사를 하고, 그 후 유저 프로세스가 다시 이 커널 메모리 영역의 버퍼에서 자신의 유저 메모리 영역으로 복사를 한번 더 해야한다. 두번 복사를 하는 것이다.)
참조
그리고 하나의 파일에 대해 여러 프로세스들이 자신의 가상 주소로 페이징을 해서 여러 프로세스가 하나의 파일을 공유해서 사용할 수 있다. 당연히 프로세스간 데이터 동기화도 된다.
메모리 맵 파일이 위의 메모리 맵 IO가 가장 큰 차이점은 OS가 하는 일이 다르다는 것이다.
메모리 맵 IO의 경우 OS는 단순히 해당 데이터를 캐싱하지 않고 메모리에 쓰도록 강제하는 역할을 한다.
반면 메모리 맵 파일의 경우에는 페이지 out되어 있던 페이지 ( 처음에는 모든 파일의 데이터가 페이지 out되어 있을 것이다. )들을 메모리로 읽어오는 작업을 해야한다. 또한 페이지 out될 때 파일 페이지가 dirty하면 파일에 쓰는 작업도 수행해야한다. ( MMU가 페이지 폴트 인터럽트를 발생시키면 커널이 CPU를 점유하고 버스를 통해 디스크에게 페이지를 요청한다. )
OS가 파일 시스템 매니저와 페이지 폴트 핸들러를 조율해서 이러한 페이징 작업들이 이루어지게 한다.
자세한건 이 글을 읽기바란다.
시스템콜
CPU가 A라는 프로그램을 실행하다가 파일을 읽어야할 경우에는 어떤식으로 작업이 이뤄질까? 파일을 읽어오기 위해서는 ( Disk에 접근한다면 Disk Controller에게 명령을 통해서 ) I/O에 접근을 해야하는데, 그러기 위해선 ‘특권명령 ( 커널 모드 명령어 )’이 필요하다. CPU에서 I/O에 접근하는 명령은 모두 특권명령으로 묶여있다.
그런데 실행하고 있던 A라는 프로그램이 사용자프로그램이라면? 무슨 방법으로 I/O에 접근할 수 있을까? 이 때는 특권명령을 사용하기 위해 ‘운영체제’에게 CPU 사용권한을 넘겨줘야 한다. 이를 ‘시스템콜’이라고 부른다.
“사용자 프로그램이 운영체제의 서비스를 받기 위해 커널 함수를 호출하는 것”이라고 이해하면 된다.
이런 작업을 위해서는 메모리 공간에서의 ‘점프’가 필요하다. 하나의 프로그램 내에서 작업 순서가 바뀌는 것과는 달리 다른 프로그램(사용자 프로그램에서 운영체제로의)으로 이동하는 것은 다르기 때문이다. 그렇다면 CPU사용권을 어떻게 운영체제로 넘길 수 있을까?
이전 시간에 배운대로 Interrupt를 활용하면 된다. CPU는 하나의 작업을 마치고 항상 Interrput를 확인하기 때문이다. 그러면 이때 Interrput는 누가 요청하는 것일까?
바로 시스템콜이다. 시스템 콜은 운영체제의 권한이 필요한 사용자 프로그램 스스로가 기계어를 통해 CPU에게 요청하는 것을 말한다.
이후 OS는 IO 디바이스 컨트롤러에게 IO 작업 요청을 하고, 다른 프로세스에 CPU를 넘긴다.
IO 디바이스 컨트롤러가 IO 작업을 끝내면 IO는 CPU에 인터럽트를 건다. 그럼 CPU는 다시 프로세스를 점유하고 인터럽트를 처리한다.
즉 IO 작업을 위해서는 두 번의 인터럽트가 필요한데, 한번은 OS에 CPU 점유를 넘기기 위해서, 다른 한번은 IO 작업이 끝나고 IO 디바이스 컨트롤러가 CPU에게 IO 작업이 끝났다는 것을 알리기 위해서 인터럽트가 사용된다.
폴링
CPU와 IO 디바이스의 핸드셰이킹의 수단들 중 하나이다.
- CPU는 디바이스에 있는 busy 비트를 반복적으로 체크한다. 이는 busy 비트가 clear될 때까지 반복한다.
- 여기서 CPU는 busy 비트를 체크하는 동안에는 다른 일을 할 수 없다. 즉 다른 일 안하고 계속 busy 비트를 체크하고 있어야한다는 것이다. 그렇기 때문에 인터럽트와 달리 즉각적으로 디바이스의 상태를 알 수 있다.
- busy 비트가 clear가 되면 CPU는 데이터 중 1 바이트를 data out 레지스터에 쓴다. 그리고 커맨드 레지스터에 write 비트를 설정한다.
- CPU는 디바이스에 대기 중인 커맨드를 알리기 위해 커맨드 레지스터에 커맨드 ready 비트를 쓴다.
- 디바이스 컨트롤러가 커맨드 ready 비트가 쓰여진 것을 보면 먼저 busy 비트를 설정한다.
- 그 후 디바이스 컨트롤러는 커맨드 레지스터를 읽고 write 비트가 설정된 것을 보고, data-out 레지스터로부터 데이터 중 일부를 읽고 그 데이터를 출력한다.
디바이스 컨트롤러는 상태 레지스터에 있는 error 비트, command-ready 비트를 지우고 마지막으로 busy 비트를 지움으로서 동작의 완료를 알린다. ( 시그널링 )
폴링은 그 장치와 컨트롤러가 빠르고, 전송할 중요한 데이터가 있다면 매우 빠르고 효율적이다. 그러나 만약 CPU가 busy 루프에서 오랫동안 장치를 기다려야하고 ( busy 비트를 반복적으로 체크하는 것을 말하는 것 같다. ), 드물게 전송하는 데이터를 위해서도 반복적인 busy bit 검사가 필요하기 때문에 비효율적일 수도 있다.
do
{
status = check_status();
} while (status == NOT_DONE);
폴링을 코드화하면 위와 같은데 CPU는 다른 일을 하지않고 무한정으로 IO 상태를 체크하고 있다.
인터럽트 ( Interrupts )
인터럽트는 CPU가 IO 상태변호를 계속 체크하고 있지 않고 다른 일을 할 수 있게해준다.
장치가 CPU의 즉각적인 응답이 필요 없는 데이터 전송이 있거나, 진행중이던 동작이 끝났음을 CPU에게 알릴 수 있게 해주는다.
- CPU는 매 명령어 연산 후 인터럽트-request line를 검사한다. ( 인터럽트-request line은 CPU에 포함되어있다. ) ( 폴링은 다른 일 안하고 인터럽트 시그널만 기다리고 있는데 반해 CPU는 다른 일을 하면서 매 명령 후 인터럽트 라인을 검사한다. )
- 디바이스 컨트롤러는 인터럽트 request line에 신호를 보냄으로서 인터럽트를 발생시킨다.
- 그럼 CPU는 현재 상태를 잠시 저장하고, 메모리 내에 어떤 특정 주소에 있는 인터럽트 핸들러 루틴에 제어권을 넘긴다. ( CPU는 인터럽트를 캐치하고 인터럽트 핸들러에게 전달한다. )
- 인터럽트 핸들러는 인터럽트의 원인을 파악하고, 해당 인터럽트에 맞는 동작을 수행한 후, 상태를 복구시킨 후, CPU에 제어권을 되돌려주기 위해 인터럽트에서 복귀 명령어를 실행한다. ( 인터럽트 핸들러는 장치를 위한 동작을 수행함으로서 인터럽트를 clear한다. ) ( 인터럽트는 인터럽트 핸들러가 디바이스를 처리하는데 반해 폴링은 CPU각 직접 디바이스를 처리한다. )
- 인터럽트를 처리할 때 CPU가 유저 모드에 있다면 잠시 커널 모드로 전환이 된다.
- ( 복구된 상태가 인터럽트 이전과 같을 필요는 없다. )
void dsp_task()
{
..................
register_isr (status_alarm);
wait_signal (OPERTION_DONE_SIG);
...................
}
void status_alarm()
{
send_signal (dsp_task, OPERATION_DONE_SIG);
}
인터럽트를 코드화하면 위와 같은데 다른 일을 하고 있다가 특정 시그널 ( 인터럽트 )이 들오오면 즉각적으로 해당 작업을 수행한다.
겉으로 보기에는 무한정 인터럽트 신호를 기다리는 폴링 방법이 더 빠를 것 같지만 폴링 루프가 긴 경우에는 인터럽트가 더 빠를 수 있다.
인터럽트는 언제나 인터럽트가 발생하면 처리가 가능하지만, 폴링은 매 루프 체크를 할 때만 처리를 하는 것이다.
아래 사진은 인터럽트를 통한 IO의 절차를 보여준다.
위의 사진은 간단한 인터럽트를 통한 IO에 대해 잘 묘사하고 있지만, 현대 컴퓨팅에는 위의 사진을 더 복잡하게 만드는 세가지 요소가 있다.
- 하나는 중요한 동작 동안에는 인터럽트 핸들링을 잠시 지연시킬 필요성.
- 다른 하나는 모든 장치들에 대해 폴링을 하지 않기 위해 인터럽트 핸들러가 실행하거나 주의를 기울어야할 장치를 결정할 필요성.
- 마지막으로 멀티 레벨 인터럽트인데, 이는 인터럽트의 우선 순위에 따라 시스템이 적절한 응답을 하기 위함이다.
이러한 문제들은 현대 컴퓨터 아키텍쳐에서 인터럽트 컨트롤러를 통해 해결할 수 있다.
- 오늘날 대부분의 CPU들은 두가지 인터럽트-request line을 가지고 있다. 하나는 중요한 에러를 다루는 non-maskable, 다른 하나는 CPU가 중요한 동작을 하는 동안에는 CPU가 잠시 무시할 수 있는 maskable이 그것이다.
- 인터럽트 메커니즘는 인터럽트 vector라고 불리는 어떤 테이블의 오프셋으로 사용되는 수들의 작은 집합인 address를 받는다.
- 활용 가능한 인터럽트 핸들러의 수는 여전히 정의된 인터럽트의 수를 초과한다. 그래서 여러 인터럽트들은 인터럽트 chained될 수 있다. 인터럽트 vector들의 주소들은 인터럽트 핸들러들의 링크드 리스트들에 대한 head 포인터들이다. ( 그러니깐 인터럽트 핸들러는 일종의 주소를 받는데 이 주소는 인터럽트 핸들러의 위치 주소를 가지고 있는 인터럽트 vector의 index로 사용된다. )
- 현대 인터럽트 하드웨어는 또한 인터럽트 우선순위 단계를 지원한다. 이는 높은 우선 순위를 가진 인터럽트를 처리하는 동안에는 낮은 우선 순위의 인터럽트를 무시할 수 있게 해주고, 낮은 우선 순위의 인터럽트를 처리하는 것을 인터럽트하여 높은 순위 인터럽트를 우선 처리하게 해준다.
컴퓨터를 키면 시스템은 어떤 장치들이 있는지 확인하고 인터럽트 테이블에 적절한 인터럽트 핸들러 주소를 저장한다.
동작하는 동안 디바이스들은 인터럽트를 통해 커맨드의 처리 완료나, 에러들을 알린다 ( 시그널링 한다. )
분모가 0인 나누기, 잘못된 메모리 접근, 커널 모드 명령어로의 접근 시도와 같은 예외들도 인터럽트를 통해서 시그널될 수 있다.
Time Slicing과 컨테스트 스위칭 또한 인터럽트 메커니즘을 통해 구현할 수 있다.
- 스케줄러는 유저 프로세스에 제어권을 넘기기 전에 하드웨어 타이머를 설정한다. ( 일정 시간 후 다시 스케줄러가 제어권을 가지고 스케줄러를 하기 위함 )
- 타이머가 인터럽트 request line에 인터럽트를 발생시키면 CPU는 상태 저장을 하고, 적절한 인터럽트 핸들러에 제어권을 넘긴다. 이는 스케줄러를 동작시킨다.
- 스케줄러는 타이머를 초기화하고, 인터럽트에서 복귀 명령어를 만들기 전 다양한 프로세서들의 상태 복구를 한다.
비슷한 예로는 가상 메모리 페이징 시스템이 있다. 페이지 폴트는 인터럽트를 발생시키는데 이는 결국 위에서 설명한대로 IO 요청, 컨테스트 스위치로 이어진다. 이후 인터럽트 된 프로세스를 대기 큐에 넣고, 동작할 다른 어떤 프로세스를 선택한다. ( 그러니깐 페이지 폴트로 페이지를 다시 가져오라는 IO를 요청한 후 CPU는 페이지를 가져오는 것을 기다리고 있지 않고 컨테스트 스위치를 하여 다른 프로세스를 실행하고 있는다. ) ( IO 요청 후 CPU가 무엇을 할지는 스케줄링 알고리즘, 정책에 따라 다르다. )
시스템 콜들은 트랩이라고 불리는 소프트웨어 인터럽트를 통해 구현될 수 있다. 프로그램 ( 유저 모드 )이 커널 모드에서 실행될 필요가 있는 동작이 필요할 때, 프로그램은 커맨드 정보와 수행할 데이터의 주소를 특정 레지스터에 쓴다. 그 후 소프트웨어 인터럽트를 발생시킨다. 시스템은 상태를 저장하고 커널모드에서 유저 모드로부터 받은 요청을 처리하기 위해 적절한 인터럽트 핸들러를 호출한다. ( 유저 모드 프로그램이 커널 모드 권한을 사용하기 위해 인터럽트를 활용하는 것이다 ) 소프트웨어 인터럽트들은 대개 낮은 우선순위를 가지는데, 이는 이 소프트웨어 인터럽트들이 한정된 버퍼링 공간을 가진 디바이스들의 인터럽트 같이 급하게 처리해야하지는 않기 때문이다. ( 버퍼링 공간이 한정되면 그 버퍼링 공간이 다 차기 전 얼른 해당 버퍼링 공간을 처리해주어야 한다. )
또한 인터럽트는 커널 동작을 제어하기 위해서도 사용되고, 최적의 성능을 위해 동작들을 스케줄하기 위해서 사용된다. 예를 들면, 디스크의 “읽기 동작 완료”는 두 가지 인터럽트를 발생시킨다. - 하나는 높은 우선 순위 인터럽트로 장치의 완료를 알리고, 하드웨어가 idle 상태에 빠져있지 않게 만들기 위해 다음으로 처리할 디스크 요청을 전송하는 것이다.
- 다른 하나는 낮은 우선 순위 인터럽트로 커널 메모리 공간에서 유저 공간으로 데이터를 전송하고, 대기 큐에서 준비 큐로 프로세스를 전송한다.
Solaris OS는 멀티 스레드 커널을 사용하고 다양한 인터럽트 핸들러들에게 다양한 쓰레드를 배정하는 스레드를 우선 순위로 둔다. 이는 다양한 인터럽트들에 대한 “동시성” 핸들링을 하게 해주고, 높은 우선 순위 인터럽트들이 낮은 우선 순위 인터럽트, 유저 프로세스들보다 우선 처리되게 보장한다.
직접 메모리 접근 ( Direct Memory Access )
DMA에 대해 다루기 전에 우선 Programmed IO ( PIO ) 에 대해 먼저 알아보겠다. PIO는 대표적으로 위에서 배운 IO mapped IO, Memory mapped IO 방식이 대표적 인데 두 방법 모두 IO 통신을 위해서는 CPU가 버스에 주소를 써주어야한다. CPU와 주변 장치간의 데이터를 통신을 할 때마다 매번 CPU의 개입이 필요하다는 것이다. IO 통신을 위해 CPU가 항상 상태 비트를 관찰하고 디바이스 컨트롤러의 레지스터에 매번 데이터를 써주는 것은 할 일이 많은 CPU에게 큰 부담이다.
그래서 이러한 일을 CPU가 아닌 Direct Memory Access, DMA, Controller로 알려진 특별한 프로세서에게 맡기는 것이 DMA의 핵심이다.
다시 말하면 기존에 IO mapped IO, Memory mapped IO 방식에서 CPU가 하던 일을 DMA가 대신하는 것이다.
CPU는 커맨드 블록을 메모리에 써서 DMA에게 명령을 하는데, 이 커맨드 블록은 전송할 데이터의 위치, 데이터가 전송될 위치, 전송할 데이터의 사이즈를 가지고 있다. 그럼 DMA 컨트롤러는 전송받은 것들을 토대로 데이터 전송을 처리하고, 데이터 전송이 끝나면 CPU에 인터럽트를 발생시킨다.
여기서 DMA가 필요한 이유가 드러나는데 DMA가 없다면 CPU가 직접 데이터 전송을 해주어야한다. 생각해보자. 만약 80바이트의 데이터를 전송한다고하면 CPU는 워드 사이즈인 8바이트씩 80바이트를 보내는 동안 다른 일을 하지 못한다.
그래서 대신 CPU가 DMA 컨트롤러에게 80바이트 데이터를 전송하라는 명령을 내리면 DMA 컨트롤러가 이를 대신 수행하는 것이다.
간단한 DMA 컨트롤러는 거의 모든 현대 PC에서 보편적인 요소이고, 많은 bus-mastering IO 카드들은 그들 자신의 DMA 하드웨어를 가지고 있다. 모든 주변 장치가 공유하는 DMA 컨트롤러도 있고 IO 장치가 DMA 컨트롤러를 가질 수도 있다.
전자의 경우 IO 장치는 이 DMA 컨트롤러에게 데이터 전송, 읽기를 요청한다.
DMA 컨트롤러들과 그들 장치들 사이의 handshaking은 DMA-request, DMA-acknowlege 라고 불리는 두 선들을 통해 이루어진다.
DMA 전송이 진행되는 동안 CPU는 버스에 접근할 수 없지만 ( 당연히 DMA가 전송한 데이터는 버스를 통해 이동하니 ), CPU는 코어의 레지스터, 캐시들에는 접근할 수 있다. ( DMA 전송은 디바이스와 DRAM과의 데이터 전송이니 DMA 전송 동안 CPU는 DRAM말고 레지스터, 캐시들에는 여전히 접근할 수 있다는 것이다. )
DMA는 피지컬 주소, 피지컬 주소에 매핑된 가상 주소 모두에 적용할 수 있다. 후자의 경우는 Direct Virtual Memory Access, DVMA라고 부르고 메인 메모리 칩을 사용하지 않고 하나의 메모리 매핑된 장치에서 다른 장치로 데이터를 직접 전송할 수 있게해준다. ( 메모리 Mapped IO의 메모리들 사이에 데이터 전송이 가능하다는 것 같다. )
유저 프로세스들에 의한 직접적인 DMA 컨트롤러에의 접근은 연산을 빠르게하지만, 일반적으로는 보안, 데이터 보호를 이유로 현대 OS에서는 금지되어있다. ( 그러니깐 데이터 전송, 읽기를 위해 DMA 컨트롤러에게 명령을 하려면 반드시 시스템콜을 호출하여 커널 모드에 들어가 DMA 컨트롤러에게 명령을 내려야한다. ) ( 그래서 유저 프로세스는 커널 모드 함수 ( 시스템 콜 )를 호출해서 간접적으로 DMA를 활용한다. )
아래 사진은 DMA 처리 과정을 보여준다.
CPU가 I/O로부터 데이터를 읽어오려는 경우 우선 CPU는 디바이스 드라이버에게 CPU 제어권을 넘기고 디바이스 드라이버는 디바이스 컨트롤러에게 메모리의 어떤 주소로 데이터를 읽어오라고 명령한다. 그럼 디바이스 드라이버는 IO의 디바이스 컨트롤러에게 해당 주소로 데이터를 전송해달라고 요청한다. 디바이스 컨트롤러가 해당 데이터를 전송할 준비가 되면 DMA-request 와이어로 시그널을 보낸다. 이 시그널은 DMA 컨트롤러가 메모리 버스를 마스터링하고, 전송할 주소를 주소 버스에 쓰게한다. 그 후 DMA-ACK 와이어에 시그널을 전송한다. 그럼 디바이스 컨트롤러는 DMA-ACK 시그널을 받고 데이터를 메모리에 전송한다. 그후 DMA-request 시그널을 제거한다. 그 후 이 메모리 컨트롤러는 이 데이터를 전송받고 메모리에 쓸 것이다. 그리고 커맨드 블록에서 요청했던 사이즈만큼의 전송이 다 끝나면 DMA 컨트롤러는 CPU에게 인터럽트를 발생시킨다.
DMA 컨트롤러가 데이터를 전송하는 동안은 당연히 메모리 버스를 점유하고 있으니 CPU는 메모리 버스를 사용할 수 없다. CPU는 오직 레지스터와 캐시에만 접근할 수 있다는 것이다. 이렇게 DMA 컨트롤러가 CPU의 메모리 버스를 막는 것을 Cycle Stealing이라 하는데 이는 CPU가 메모리 동작을 못하게해 CPU 사용량을 낮추지만, CPU는 여전히 레지스터, 캐시에 접근 가능하니 그러한 연산을 하면된다. 결과적으로는 DMA를 통해 전체 시스템의 성능은 향상한다.
( 여기서 CPU와 DMA가 동시에 메모리 버스에 접근하여 동시에 읽기/쓰기 동작을 하면 안되니 메모리 컨트롤러가 CPU, DMA의 메모리 버스로의 접근를 중재한다. )
또한 DMA가 메모리버스를 통해 메모리의 데이터를 다른 IO 장치에 보낼 때도 결국은 메모리에서 메모리 버스에서 메모리의 데이터를 가져오니 Cache Coherent가 작동한다.
DMA는 CPU가 아닌, 두 가지 주변 장치간 혹은 메모리와 다른 장치간 데이터 전송을 CPU를 대신하여 수행하는 것이다.
메모리 맵 IO는 CPU가 메모리 버스를 통해 디바이스와 통신 하는 것이다. ( 메모리와는 상관없다. )
DMA VS Memory Mapped IO, 체택된 답글 댓글 읽자
어플리케이션 IO 인터페이스
다양한 장치로의 유저 어플리케이션 접근은 레이어링, 디바이스용 코드를 디바이스 드라이버들에 캡슐화한 후 넣어 이룰 수 있다. 이렇게 디바이스 드라이버들에는 디바이스용 코드가 캡슐화되어 들어가는데 반해 어플리케이션 레이어에서는 모든 ( 최소한 다목적 ) 장치들에 대해 공통된, 범용적인 인터페이스를 가진다.
아래는 커널 IO 구조를 보여주는 사진이다. ( 커널 IO 시스템이 디바이스 드라이버를 통해 디바이스 컨트롤러와 소통한다. )
아래 사진은 다양한 IO 장치들의 특성을 보여준다.
대부분 장치들은 블록 IO, 문자 IO, 메모리 맵 파일 접근, 네트워크 소켓으로 분류할 수 있다. 시간, 시스템 타이머와 같은 몇몇 특별한 장치도 있다.
대부분의 OS들은 필요할 때 어플리케이션이 디바이스 드라이버들로 직접적으로 커맨드들을 보낼 수 있는 escape나 back door를 가지고 있다.
블록, 문자 디바이스들
블록 디바이스들은 한번에 한 블록씩 접근하고, UNIX 시스템에서는 긴 리스트 중 첫번째 문자열로 ‘b’로 시작하는 디바이스들이다. read(), write(), seek()와 같은 연산을 지원한다.
- 하드 드라이버에 있는 블록들에 직접적으로 접근하는 것은 ( 파일 시스템 구조를 거치지 않고 ) raw IO라고 불린다. 이는 OS에 의해 일반적으로 수행되는 locking이나 버퍼링을 거치지 않기 때문에 특정 연산들의 속도를 높여준다. ( 어플리케이션이 직접 locking, 버퍼링을 처리해야한다. )
- 새로운 대안으로는 direct IO가 있는데 이는 locking, 버퍼링 연산을 제거한 일반적인 파일시스템 접근을 통해 이루어진다.
메모리 맵 파일 IO는 블록 디바이스 드라이버들 위에 위치할 수 있다.
- 전체 파일을 읽는 것이 아니라, 특정 메모리 영역에 맵핑되고, 필요하다면 가상 메모리 시스템을 사용하여 메모리를 페이징한다.
- 기본적으로는 디스크에 페이지 out되어 있고 필요할 때 메모리로 페이지 in되고 mapped이 해제될 때는 수정된 페이지 ( dirty bit )만 파일에 쓴다.
-
파일로의 접근은 read(), write()와 같은 시스템 콜 ( 커널 모드 호출 )들이 아니라 일반적인 메모리 접근들을 통해 이루어질 수 있다. 이러한 접근은 흔히 실행 프로그램 코드에서 사용하는 방법이다.
- WIN32의 Memory Mapped File을 보면 특정 파일이나 페이징 파일에 대해서 Physical Memory에 파일 Mapping 오브젝트가 만들어진다. 이 파일 Mapping 오브젝트는 디스크에 있는 해당 파일의 일부분의 복사본이다.
- 만약 이 파일 Mapping 오브젝트에 데이터를 쓰면 해당 페이지는 Dirty 처리되고 Page Out될 떄 ( Mapping이 해제될 때 ) dirty인 경우 dirty한 파일 Mapping 오브젝트를 Disk에 쓰기 동작을 수행한다.
- WIND32에서는 각 프로세스 ( 프로그램 )에서 이러한 파일 Mapping 오브젝트에 접근하기 위해 파일 뷰라는 것을 각 프로세스의 가상 주소 공간에 만든다. 그럼 각 프로세스가 이 가상 주소 공간의 파일 뷰를 통해 파일 Mapping 오브젝트에 접근하여 읽거나 쓰는 방식이다.
- 어떤 파일로부터 나온 파일 Mapping 오브젝트는 모든 프로세스가 공유할 수 있다. Physical 메모리에 파일 Mapping 오브젝트는 한개만 있고 여러 프로세스가 각자의 가상 주소 공간에 이 파일 Mapping 오브젝트를 참조하는 방식이다. 당연히 프로세스가 쓰기 동작을 수행하면 프로세스간의 동기화도 된다.
네트워크 디바이스들
네트워크 접근은 로컬 디스크 접근과 다르기 때문에 대부분의 시스템은 네트워크 디바이스들을 위한 전용 인터페이스를 제공한다.
흔하고 널리 사용되는 인터페이스 중 하나는 소켓 인터페이스이다. 이 소켓 인터페이스는 두 개의 네트워크 엔티티들을 연결하는 케이블, 파이프라인과 같은 역할을 한다. 데이터는 한쪽 끝의 소켓으로 전송할 수 있고, 다른 쪽 끝에서 차례대로 읽을 수 있다. 소켓들은 일반적으로 전이중 통신이고, 양방향 데이터 전송도 할 수 있게해준다.
select() 시스템 콜은 서버 ( 다른 어플리케이션들 )가 모든 이용 가능한 소켓들을 폴링하지 않고도 대기 중인 데이터를 가지고 있는 소켓이 어떤 것인지 알려준다.
클록, 타이머
세가지 종류의 시간 서비스들이 현대 시스템에서 흔히 사용된다.
- 현재 시간
- 이전 이벤트 후 경과한 시간
- 타임 T에 이벤트 X를 발동하기 위한 타이머 설정
불행히도 시간 연산은 모든 시스템에 표준으로 존재하지 않는다.
프로그래밍이 가능한 인터럽트 타이머, PIT는 연산을 발동하기 위해 사용될 수 있고, 경과한 시간을 측정하기 위해서도 사용될 수 있다. 미래의 특정 시점에 인터럽트를 발동하기 위해서도 사용될 수 있고, 주기적으로 인터럽트를 발동시키기 위해서도 사용된다. - 스케줄러는 타임 slice를 끝내는 인터럽트를 발동시키기 위해 PIT를 사용한다.
- 디스크 시스템은 디스크로 버퍼를 flush하는 것과 같은 주기적인 버퍼 관리를 스케줄링하기 위해서 PIT를 사용한다.
- 네트워크들은 완료하는데 오랜 시간이 걸리는 연산을 반복하거나 중단시키기 위해 PIT를 사용한다.
- 타이머 이벤트들의 순차적 리스트를 관리하거나 다음으로 스케줄된 이벤트가 발생했을 때 물리적 타이머를 꺼지도록 설정하여 실제 존재하는 타이머 개수보다 더 많은 타이머를 구현할 수 있다.
대부분의 시스템에서 시스템 클록은 PIT에 의해 생성되는 인터럽트의 수를 세는 것으로 구현된다. 불행이도 이는 PIT의 인터럽트 빈도에 비례하게 제한될 수 밖에 없는데 시간이 지남에 따라 문제가 될 수도 있다. 대안으로는 높은 정확도, 정밀도를 가진 고빈도 하드웨어 카운터에 직접적인 접근을 제공하는 것인데, 아쉽게도 이 고빈도 하드웨어 카운터는 인터럽트를 지원하지 않는다. ( 위에서 예시로 보여준 것들에 활용할 수 없다. )
블락킹, 논블락킹 IO
블락킹 IO 요청이 발생하였을 때 프로세스는 대기 큐로 이동되고, IO 요청이 완료될때까지 계속 기다리다 IO 요청이 끝나면 준비 큐로 돌아간다. 한 프로세스가 대기 큐로 이동해 있는 동안에는 다른 프로세스들이 CPU를 점유하고 동작한다. ( 쉬운 예시를 들어보면 C++에서 cin 함수를 사용하면 키 값이 들어올 때까지 프로그램은 아무것도 하지 않고 대기한다. )
논블록킹 IO 요청의 경우, 요청된 IO 동작이 ( 완전히 ) 발생하였는지 여부와 관계 없이 IO 요청은 즉각적으로 반환된다. 이를 통해 프로세스는 input이나 output이 없더라도 완전히 대기 상태에 들어가지 않고 ( 데이터를 기다리는 동안 아무 것도 않하고 기다리지 않고 ) 프로그램을 계속 진행한다.
프로그래머가 논블록킹 IO를 구현하는 방법 중 하나는 멀티 스레드 프로그래밍을 하는 것이다. 이를 통해 한 스레드가 블록킹 IO 호출을하면 ( 그럼 이 스레드는 대기 큐에 들어간다 ), 다른 스레드들이 프로그램의 다른 동작을 계속해 수행해나간다.
논블록킹 IO의 변형으로는 비동기 IO가 있다. 비동기 IO는 프로세스가 IO 요청 후 즉각적으로 다음 동작을 계속 수행하게 해주고, IO 동작이 끝나고 데이터가 이용 가능해지면 프로세스에게 곧바로 알려준다. ( 프로세스의 변수를 바꾸거나, 소프트웨어 인터럽트, 콜백 함수를 통해 알려준다. ) ( 일반적인 논블록킹 IO 또한 결과 데이터가 이용 가능하든 아니든 즉각적으로 반환을 해주지만 ( 다음 동작을 수행하게끔 해주지만 ), IO 동작이 끝났을 때 알려주지 않는다. )
아래 사진에서 (a)는 동기성 IO, (b)는 비동기성 io 과정을 보여준다.
커널 IO 하부시스템
IO 스케줄링
IO 요청들을 스케줄링하는 것은 전체적인 효율성을 높일 수 있다. 스케줄링 요청에 우선 순위가 사용된다.
전통적인 예는 디스크 접근의 스케줄링이 그 예이다.
버퍼링, 캐싱 또한 더욱 유연한 스케줄링 옵션을 가능하게 해준다.
많은 디바이스들의 시스템에서는 별개의 요청 큐들이 각각의 장치들을 위해 존재한다.
아래 사진은 디바이스 상태 테이블을 보여준다.
버퍼링
IO의 버퍼링은 주로 3가지 이유로 수행된다.
- 두 장치간 속도 차이. 느린 장치는 아마 버퍼에 데이터를 쓸 것이다. 그리고 버퍼가 다 차면 전체 버퍼는 한번에 빠른 장치로 전송된다. 버퍼 전송이 이루어지는 동안에도 느린 장치가 데이터를 쓰기 위해서 더블 버퍼링이 사용된다. ( 이러한 더블버퍼링은 렌더링에도 사용되는데 이는 스크린 버퍼를 마저 다 그리지 못한 상황에서 유저가 스크린 버퍼를 보는 것을 막는다. )
- 데이터 전송 크기 차이. 버퍼는 특히 네트워킹 시스템에 사용되는데 이는 데이터 전송을 위해 메세지들을 더 작은 패킷으로 쪼개고, 패킷을 받은 후 다시 합친다.
- copy semantics을 지원하기 위해. 예를들면 어플리케이션이 디스크 쓰기를 위한 요청할 때, 데이터는 유저 메모리 영역에서 커널 버퍼로 복사된다. 이때 어플리케이션은 디스크에 쓰기 요청을 보냈던 메모리 영역에 쓰기 동작을 여전히 수행할 수 있는데, 이 때 어플리케이션이 데이터를 써도 디스크로 전송되는 데이터는 디스크 쓰기 요청이 만들어졌을 때의 데이터이다. ( 그러니깐 커널 버퍼로 데이터를 임시로 복사한 후 디스크로 전송을 함으로서, 어플리케이션이 디스크에 쓰기로 했던 데이터를 덮어써도 디스크에 쓰여지는 데이터는 덮어쓴 데이터가 아니라 커널 버퍼로 옮겨두었던 데이터가 디스크에 쓰이기 때문에, 디스크 쓰기 요청을 한 후에도 해당 메모리에 다른 쓰기 동작을 해도 괜찮다는 것이다. )
아래 사진은 장치별 데이터 전송 속도를 보여준다.
캐싱
캐싱은 데이터가 일반적으로 저장되는 위치보다 더 빠른게 접근할 수 있는 위치에 데이터의 복사본을 저장해두는 것을 말한다.
버퍼링과 캐싱은 매우 비슷하지만, 버퍼는 해당 데이터의 유일한 복사본을 가진다는 점에서, 캐시는 다른 곳에 저장된 다른 데이터의 복사본이라는 점에서 둘은 차이가 있다.
버퍼링과 캐싱은 매우 흡사하고 같은 저장 공간이 사용된다. 예를 들면 버퍼가 디스크에 쓰인 후 메모리에 있는 복사본은 캐싱된 복사본으로 사용될 수 있다. ( 그 버퍼가 다른 목적을 위해 사용되기 까지는 )
스풀링(Spooling), 디바이스 Reservation
스풀 ( Simultaneous Peripheral Operations On-Line )은 교차된 데이터 스트림들을 지원할 수 없는 프린터와 같은 주변기기를 위해 데이터를 버퍼링한다.
만약 여러 프로세스들이 동시해 프린트를 하기를 원한다면 그들은 각각 프린트할 데이터를 스풀 디렉토리에 있는 파일들에 데이터를 전송하여 저장한다. 각각의 파일이 닫혔을 때, 어플리케이션은 프린트 Job이 끝났다고 보고, 프린트 스케줄러는 각각의 파일을 한번에 하나씩 프린트에 보낸다.
스풀 큐를 보고, 작업을 한 큐에서 다른 큐로 옮기고, 때떄로는 큐에 있는 작업들의 우선 순위를 바꾸는 등의 것들이 지원된다.
스풀 큐들은 다용도 ( 레이저 프린터와 같은 )로 사용될 수도, 특수한 용도 ( 42번째 프린트 )로도 사용될 수 있다.
또한 OS들은 프로세스들이 특별한 장치에 독점적인 접근을 요청하거나, 얻고 혹은 장치가 이용 가능할 때까지 프로세스가 기다릴 수 있게 하는 등의 기능을 제공한다.
에러 핸들링
IO 요청은 일시적인 ( 버퍼 오버플로우 ) 혹은 영구적인 ( 디스크 충돌 ) 이유로 실패할 수 있다.
IO 요청들은 문제를 알려주는 에러 비트를 반환한다. 유닉스 시스템들은 또한 어떤 특정 에러가 발생했을음 가리키는 1에서 100 혹은 잘 정의된 값을 errno 전역 변수에 설정한다.
SCSI 디바이스들과 같이 몇몇 장치들은 에러에 대해 훨씬 더 디테일한 정보를 제공할 수 있고, 심지어는 호스트에 의해 요청될 수 있는 온 보드 에러 로그를 유지할 수 있다.
IO 보호
IO 시스템은 어떤 에러를 유발하는 우연한 혹은 의도된 IO 요청으로부터 시스템을 보호한다.
유저 어플리케이션들은 유저 모드에서 IO를 수행하는 것이 금지되어 있다. 모든 IO 요청들은 반드시 커널 모드에서 수행되어야하는 시스템 콜을 통해서만 다루어진다.
메모리 맵 지역, IO 포트들은 메모리 관리 시스템에 의해서 보호되어야하지만, 그렇다고 유저 프로그램의 그러한 지역들로의 접근을 완전히 거부할 수는 없다. ( 예를들면 비디오 게임들 그리고 몇몇 어플리케이션들은 성능 향상을 위해 직접적으로 비디오 메모리 VRAM에 데이터를 쓸 수 있다. 시스템 콜 없이 직접 IO를 수행할 수 있다는 것이다. ) 대신 특정 윈도우에 상응하는 스크린 메모리의 일부분 같이 메모리의 특정 부분들에 한번에 오직 하나의 프로세스만이 접근할 수 있게 메모리 보호 시스템은 접근 제한을 둔다.
아래의 사진은 IO를 수행하기 위해 시스템 콜을 사용하는 과정을 보여준다.
커널 데이터 구조
커널은 IO 시스템에 속해있는 여러 중요한 데이터를 관리한다. ( 그 예로는 오픈 파일 테이블과 같은 것이 있다. )
이러한 구조들은 객체 지향적이고, 공통 인터페이스를 통해서 다양한 IO 장치들이 유연하게 접근할 수 있게 해준다.
윈도우 NT는 객체 지향에서 한발 더 나아가서 다양한 중개자를 통해서 소스에서 장치로 메시지 전달 시스템을 IO로 구현한다.
아래 사진은 유닉스의 IO 커널 구조이다.
IO 요청을 하드웨어 동작으로 변환하는 과정
유저들은 파일명을 사용해서 파일의 데이터를 요청하는데, 이 파일명은 특정 디바이스 드라이버에 의해 관리되는 데이터의 블록들에 완전히 매핑되어 있다.
DOS는 어떤 특정 디바이스를 구분하기 위해 콜롬(:) 구분자를 사용한다. ( 예를들면 C:, D:, E: 등등이 있다. )
유닉스는 특정 마운트된 장치들에 파일명 접두사 ( ex. /usr )를 매핑하기 위해 마운트 테이블을 사용한다. 만약 파일명의 접두사들과 일치하는 엔트리가 마운트 테이블에 여러가 있다면, 가장 긴 접두사를 선택한다. ( 그러니깐 파일명의 접두사 /usr/home과 /use이 둘다 마운트 테이블에 있는 경우에는 가장 긴 /usr/home을 선택한다는 것이다. )
유닉스는 /dev에 위치한 특별한 디바이스 파일들을 사용하는데, 이는 물리적 디바이스들을 직접적으로 접근하고 대표하기 위함이다.
- 각각의 디바이스 파일은 그것과 관련된 메이저한, 마이너 넘버를 가지는데 이는 파일 사이즈가 저장되고 표시되는 숫자이다.
- 메이저 넘버는 디바이스 드라이버들의 테이블 인덱스로 사용되고, 이는 어떤 디바이스 드라이버가 이 장치를 다루는지가 저장되어 있다. ( 인덱스로 해당 장치의 디바이스 드라이버를 찾아간다는 것 같다. )
- 마이너 넘버는 디바이스 드라이버에 전달되는 매개변수로, 어떤 장치가 접근하는지를 알려주고, 얼마나 많은 장치들이 특정 디바이스 드라이버에 의해서 다루어지는지를 나타낸다.
다양한 룩업 테이블과 매핑들은 다양한 장치들의 접근을 유연하게 만들고, 다양한 장치들로의 접근을 유저들에게 어느 정도는 명확하게 만들어준다.
아래 사진은 IO로 부터 읽기 IO 요청 ( 블록킹 )이 어떻게 처리되는지를 보여준다.
STREAMS
UNIX에서 스트림는 유저 프로세스와 디바이스 드라이버간의 양방향 파이프라인을 제공한다.
유저 프로세스는 스트림 헤드와 통신한다.
디바이스 드라이버는 디바이스 종단과 통신한다.
여러 스트림 모듈들이 스트렘이 들어갈 수 있다. 이러한 모듈들은 스트림을 통과하는 데이터들을 필터링하거나 수정할 수 있다.
각각의 모듈은 읽기 큐와 쓰기 큐를 가지고 있다.
플로우 컨트롤이 스트림을 적절하게 도와줄 수 있는데, 그 예로는 인접한 모듈이 데이터를 받을 준비가 될 때까지 데이터를 잠시 버퍼해두는 것을 돕는 것이 있다.
유저 프로세스들은 read(), write()를 사용하여 스트림 헤드와 통신한다.
스트림 IO는 비동기 ( 논블록킹 )적이지만, 유저 프로세스와 스트림 헤드 사이의 인터페이스는 동기적이다.
디바이스 드라이버는 자신의 장치로부터 오는 인터럽트에 반드시 응답해야한다. 만약 인접한 모듈이 데이터를 받을 준비가 되지 않았거나, 디바이스 드라이버의 버퍼가 다 찬 경우, 일반적으로는 데이터가 버려진다.
스트림들은 UNIX에서 널리 사용되고, 디바이스 드라이버들에게 가장 선호되는 방법이다.
아래 사진은 스트림의 과정을 보여준다.
성능
IO 시스템은 전체 시스템 성능에 중요한 요소이고, 시스템의 주요 컴포넌트들에 많은 부담을 줄 수 있다. ( 인터럽트 핸들링, 프로세스 컨테스트 스위칭, 메모리 접근, 버스 경합 상황, 디바이스 드라이버를 위한 CPU 로드 등이 그러한 부담이 되는 요소들이다. )
인터럽트 핸들링은 상대적으로 값 비싸고 ( 느리다 ). 그래서 busy-waiting에 소모되는 시간이 그렇게 크지 않다면 그냥 인터럽트를 통한 IO 보다 프로그래밍된 IO ( 소프트웨어 IO, 그냥 loop문 놓고 IO 완료되기를 기다린다. 이러면 다른 프로세스에 CPU 안 넘겨줄 수 있다. 오래걸리면 당연히 다른 프로세스로 넘어감. )가 더 빠를 수 있다.
네트워크 트래픽 또한 시스템에 큰 부담을 줄 수 있다. 아래 사진은 Telnet 세션에 하나의 문자가 입력되었을 때 발생하는 이벤트들을 순서대로 보여주고 있다. 썬즈사는 100에서 수 천개에 이르는 Telnet 세셧들을 동시에 처리하기 위해 커널 내 스레드를 사용한다.
다른 시스템들은 CPU로부터 오는 IO 작업들의 처리의 부담을 덜어주기 위해 프론트 엔드 프로세스를 사용한다. 예를들면 터미널 concentrator는 하나의 큰 컴퓨터에 있는 단일 포트에서 수백개의 터미널 통신을 처리해준다.
효율적인 IO 처리를 위해서 여러가지 요소들이 필요하다.
- 컨테스트 스위치 수를 줄인다.
- 복사되어야하는 데이터의 가지수를 줄인다.
- 인터럽트 빈도를 줄이고, 대용량 전송을 사용하고, 필요하다면 버퍼링, 폴링을 사용한다.
- DMA를 사용해 병렬적으로 처리하려 노력한다.
- 프로세싱 처리를 하드웨어로 옮겨 IO 처리를 CPU와 버스 처리와 동시에 할 수 있게 만든다.
- CPU, 메모리, 버스, IO 처리를 적절히 조절하여서, 어느 하나가 다른 전체를 idle 상태에서 기다리게 하는 일을 없게 만든다.
새로운 IO 알고리즘의 발전은 대개 아래 사진에서 보여주는 것과 같이 어플리케이션 레벨 코드에서 온 보드 하드웨어로의 진행을 따른다. 로우 레벨 구현은 더 빠르고, 더 효율적이고, 고 레벨 구현은 더욱 유연하고 수정하기 쉽다. 하드웨어 레벨 기능은 고 수준 ( ex. 커널 ) 제어를 하기 더 힘들어질 것이다.