프로세스가 장치 접근을 직접 하지 못하는 이유
- 여러 프로그램이 동시에 장치를 조작한다면, 예상할 수 없는 방식으로 작동할 위험성이 있다.
- 원래라면 접근해서는 안되는 데이터를 훼손하거나 훔쳐 볼 위험성이 있다.
따라서 프로세스 대신해서 커널이 장치에 접근하는데, 다음과 같은 인터페이스를 사용한다.
- 디바이스 파일이라는 특수한 파일을 조작한다.
- 블록 장치에 구축한 파일 시스템을 조작한다.
- 네트워크 인터페이스 카드는 속도 등의 문제로 디바이스 파일을 사용하는 대신에 소켓 구조를 사용한다.
디바이스 파일
디바이스 파일은 장치마다 존재한다. 예를 들어 저장 장치라면 /dev/sda나 /dev/sdb 같은 파일이 디바이스 파일이다. 리눅스는 프로세스가 디바이스 파일을 조작하면 커널 내부의 Device driver라고 부르는 소프트웨어가 사용자 대신에 장치에 접근한다.
장치 0과 장치 1에 각각 /dev/AAA, /dev/BBB라는 디바이스 파일이 존재한다면 아래와 같이 조작된다.
프로세스 -> /dev/AAA 읽고,쓰기 요청 | 커널 -> 장치 0의 디바이스 드라이버 조작 | 장치 0 조작 |
프로세스는 일반 파일과 똑같은 방식으로 디바이스 파일을 조작할 수 있다. 즉, open()이나 read(), write() 같은 시스템 콜을 호출해서 각각의 장치에 접근한다. 장치 고유의 복잡한 조작은 ioctl() 시스템 콜을 사용하고, 디바이스 파일에 접근할 수 있는 건 보통 루트뿐이다.
디바이스 파일에는 다음과 같은 정보가 저장된다.
- 파일 종류: 캐릭터 장치 / 블록 장치
- 디바이스 major 번호, minor 번호: 메이저 번호와 마이너 번호 조합이 같다면 동일한 장치에 해당한다고 생각하면 된다.
디바이스 파일은 보통 /dev/ 디렉토리 아래에 존재한다.

각 줄의 첫글자가 c라면 캐릭터 장치, b라면 블록 장치다.
다섯번째 필드가 major 번호, 여섯 번째 필드가 마이너 번호다.
캐릭터 장치
character 장치는 읽고 쓰기는 가능하지만, 장치 내부에서 접근할 장소를 변경하는 탐색 조작이 불가능하다.
다음은 대표적인 캐릭터 장치다.
- 단말
- 키보드
- 마우스
예를 들어 단말의 디바이스 파일은 다음과 같이 조작합니다.
- write() 시스템 콜: 단말에 데이터를 출력
- read() 시스템 콜: 단말에서 데이터를 입력
ps ax | grep bash를 통해 device 단말을 확인하고 적당할 문자열을 써볼 수 있다.
만약 bash의 단말이 pts/9 라면, echo hello > /dev/pts/9 명령 수행시,
bash에 hello라는 문자열이 출력 될 것이다. 왜냐하면 리눅스에서 표준 출력은 단말과 연결되어 있기 때문이다.
블록 장치
블록 장치는 파일 읽기 쓰기뿐만 아니라 탐색도 가능하다. 대표적인 블록 장치는 HDD나 SSD 같은 저장 장치이다. 블록 장치에 데이터를 읽고 쓰면 일반 파일처럼 저장 장치 특정 위치에 있는 데이터에 접근할 수 있다.
사용자가 블록 디바이스 파일을 직접 조작하는 건 극히 드문일로, 보통은 파일 시스템을 경유해서 데이터를 읽고 쓴다.
mkfs.ext4 /dev/sdc7(해당 경로는 비어있어야 함) 명령어를 통해 비어있는 파티션을 만들어보자.
만든 후, mount /dev/sdc7 /mnt/ 로 마운트 하여
echo "hello world" > /mnt/testfile 명령어를 수행하여
cat /mnt/testfile 명령어를 통해 기록된 문자열을 확인할 수 있다. 이때, strings -t x /dev/sdc7 명령어를 통해 파일 내부에 있는 문자열 데이터를 파일 오프셋, 문자열 형식으로 표시할 수 있다.
# strings -t x /dev/sdc7
...
803d000 hello world
echo "HELLO WORLD" > testfile-overwrite 한 뒤,
dd if=testfile-overwrite of=/dev/sdc7 seek=$((0x803d000)) bs=1을 통해, /dev/sdc7 에 기록된 string을 덮어쓰기 할 수 있다.
파일 시스템을 다루려는데, 비어 있는 장치나 파티션이 없으면 루프 장치 기능을 사용하여 파일을 디바이스 파일처럼 다룰 수 있다.
디바이스 드라이버
프로세스가 디바이스 파일에 접근할 때 동작하는 디바이스 드라이버 커널 기능이 있다.
장치를 직접 조작하려면 각 장치에 내장된 레지스터 영역을 read/write 해야하는데, 프로세스 입장에서의 장치 조작 순서는 아래와 같다.
1. 프로세스가 디바이스 파일을 사용해서 디바이스 드라이버에 장치 조작을 요청한다.
2. CPU가 커널 모드로 전환되고 디바이스 드라이버가 레지스터를 사용해서 장치에 요청을 전달한다.
3. 장치가 요청에 따라 처리된다.
4. 디바이스 드라이버가 장치의 처리 완료를 확인하고 결과를 받는다.
5. CPU가 사용자 모드로 전환되고 프로세스가 디바이스 드라이버 처리 완료를 확인해서 결과를 받는다.
메모리 맵 입출력(MMIO)
현대적인 장치는 메모리 맵 입출력 구조를 사용해서 디바이스 레지스터에 접근한다.
MMIO로 장치를 조작한다면, 주소 공간에 메모리뿐만 아니라 레지스터도 매핑한다.
레지스터 오프셋 | 역할 |
0 | 읽고 쓰기에 사용하는 메모리 영역 시작 주소 |
10 | 저장 장치 내부의 읽고 쓰기에 사용하는 데이터 영역 시작 주소 |
20 | 읽고 쓰기 크기 |
30 | 처리 요청에 사용, 0이면 읽기 요청, 1이면 쓰기 요청 |
40 | 요청한 처리가 끝났는지 여부를 나타내는 플래그. 처리를 요청한 시점에 0이 되고 처리가 끝나면 1이 된다. |
예를 들어, 메모리 영역 100 - 200 주소에, 저장 장치 내부 주소 300 - 400 영역에 있는 데이터를 읽어온다고 가정하자.
저장 장치 레지스터가 메모리 주소 500부터 매핑되면 읽기 요청은 다음과 같이 처리된다.
1. 디바이스 드라이버가 저장 장치의 데이터를 메모리의 어디로 가져올지 지정한다.
1-1) 메모리 주소 500(레지스터 오프셋0)에 읽은 데이터를 저장할 주소 100을 기록한다.
1-2) 메모리 주소 510(레지스터 오프셋10)에 저장 장치 내부의 읽을 주소 300을 기록한다.
1-3) 메모리 주소 520(레지스터 오프셋20)에 읽은 데이터 크기 100을 기록한다.
2. 디바이스 드라이버가 메모리 주소 530(레지스터 오프셋 30)에 읽기 요청을 뜻하는 0을 기록한다.
3. 장치가 메모리 주소 540(레지스터 오프셋 40)에 요청 처리 중 상태를 뜻하는 0을 기록한다.
주소 | |
100 - 200 | 저장 장치에서 읽어온 데이터 저장할 곳 |
200 - 500 | |
500 | 100 (읽고 쓰기에 사용하는 메모리 영역 시작 주소) |
510 | 300 (읽어올 저장 장치의 시작 주소) |
520 | 100 (읽어올 데이터 사이즈) |
530 | 0 (읽기 요청) |
540 | 0 (처리 중 표시) |
550 - 1000 |
이후의 처리는 다음과 같다.
1. 장치 주소 300 - 400 영역에 있는 데이터를 메모리 주소 100부터 전송함.
2. 장치가 요청된 처리를 완료했다는 표시로 메모리 주소 540에 값을 1로 변경함.
3. 디바이스 드라이버가 요청된 처리 완료를 확인한다.
처리완료를 확인하기 위해 polling 또는 interrupt 두 가지 방법 중 하나를 사용한다.
폴링
폴링은 디바이스 드라이버가 능동적으로 장치에서 처리를 완료했는지 확인한다. 디바이스 드라이버는 주기적으로 위의 540에 해당하는 레지스터 값을 읽고, 처리 완료를 확인한다.
가장 단순한 폴링은 장치에 처리를 요청하고 처리 완료할 때까지 확인용 레지스터를 계속 확인하는 것이다.
프로세스 p0와 p1이 있고, p0가 디바이스 드라이버에 처리를 요청했다고 가정하자. CPU 사용은 아래와 같을 것이다.
CPU
p0 | p1 | p0(장치 처리 요청) | 디바이스 드라이버(계속 처리완료 확인) | p0(처리완료 확인) | p1 |
이때 p0는 장치 요청이 끝나기 전에는 다음 처리로 진행할 수 없으니 문제가 안되지만, 장치 처리와 관계없는 p1이 CPU를 사용하지 못하는 것은 시간 낭비이다.
이런 낭비를 줄이기 위해, 계속에서 장치 처리 완료를 확인하는 대신, 일정 간격을 두고 레지스터 값을 확인하는 폴링 방법이 있다. p1에서 하는 처리 중간중간에 레지스터 값을 읽는 코드를 삽입하면 된다. 간격에 따라 자원낭비가 심해지거나 사용자 프로세스에 전달되는 처리완료 시간이 너무 길어질 수 있다.
인터럽트
인터럽트는 다음과 같은 방식으로 장치가 처리 완료했는지 확인한다.
1. 디바이스 드라이버가 장치에 처리를 요청한다. 이후 CPU는 다른 처리를 실행한다.
2. 장치가 처리를 완료하면 인터럽트 방식으로 CPU에 알린다.
3. CPU는 미리 디바이스 드라이버가 인터럽트 컨트롤러 하드웨어에 등록해둔 인터럽트 핸들러 처리를 호출한다.
4. 인터럽트 핸들러가 장치의 처리결과를 받는다.
CPU
p0 | p1 | p0(장치 처리 요청) | 디바이스 드라이버(장치 처리 요청받음) | p1(장치 처리 완료까지 다른 프로세스 실행) | 디바이스 드라이버(처리 완료를 인터럽트로 받음) | p0 | p1 |
- 장치 처리가 완료할 때까지 CPU는 다른 프로세스를 실행할 수 있다.
- 장치 처리 완료를 즉시 확인 가능하다.
- 장치에서 처리가 이뤄지는 동안 동작하는 프로세스(p1)는 장치에서 무슨일이 일어나고 있는지 신경 쓸 필요가 없다.
이런 장점 때문에 폴링보다 다루기 쉬운 인터럽트를 장치 처리 완료 확인 방법으로 사용하는 경우가 많다.
시스템 시작 이후 지금까지 발생한 인터럽트 개수는 /proc/interrupts 파일을 통해 알 수 있다.
인터럽트 컨트롤러는 여러 인터럽트 요청(IRQ)을 다루는데, 요청마다 서로 다른 인터럽트 핸들러를 등록할 수 있다.
각각의 요청에 IRQ 번호를 할당하는데 이 번호가 식별자이다.
예전에는 이런 인터럽트가 모든 논리 CPU에 1초동안 1000회처럼 정기적으로 발생했는데, 요즘은 필요할 때만 타이머 인터럽트를 발생시킨다고한다. 인터럽트처리 횟수가 줄어서 CPU 모드 전환에 따른 성능 저하 방지나 소비 전력 절약에 도움이 된다.
번외로 장치 처리가 빠르고, 처리 빈도가 높다면 예외적으로 폴링을 사용하기도 한다. 인터럽트 핸들러 호출이 어느 이상의 오버헤드를 유발하고, 장치 처리가 너무 빠르면 인터럽트가 너무 자주 발생해서 처리를 따라잡지 못할 위험이 있기 때문이다.
디바이스 파일명은 바뀐다
같은 종류의 장치를 여러 개 연결한 경우 디바이스 파일명을 조심해서 다뤄야 한다. 여러 장치가 연결되어 있다면 커널은 일정한 규칙에 따라 각각 다른이름으로 디바이스 파일에(메이저 번호, 마이너 번호 조합)에 대응 시킨다. SATA나 SAS라면 /dev/sda, /dev/sdb, /dev/sdc ...
NVMe SSD라면 /dev/nvme0n1, /dev/nvme1n1, /dev/nvme2n1 ... 이런식으로 된다.
주의할 점은 이런 대응 관계는 PC를 켤때마다 바뀐다.
예를 들어, SATA 접속 방식의 저장 장치 A,B를 연결하는 경우를 가정하자.
이때 무엇이 /dev/sda가 되고 /dev/sdb가 될지는 장치 인식 순서에 달려 있다.
커널이 A, B 순서로 저장 장치를 인식했으면 /dev/sda, /dev/sdb라고 이름이 붙는다.
이후에 재시작했을 때 어떤 이유로 저장 장치가 바뀌는가?
- 다른 저장 장치를 추가: 만약에 저장 장치 C를 추가해서 인식 순서가 A -> C -> B가 되면, B장치명이 /dev/sdc가 될 것이다.
- 저장 장치 위치 변경: A와 B를 설치한 위치를 바꾸면 각각, /dev/sdb, /dev/sda가 될 것이다.
- 저장 장치 고장으로 인식 되지 않을 때: 예를 들어 A가 고장이라면 B가 /dev/sda로 인식된다.
이름이 바뀌면 뭐가 문제인가? 운이 좋으면 부팅에 실패하는 정도지만, 운이 나쁘면 데이터가 파괴된다.
이런 문제는 systemd의 udev 프로그램이 만드는 영구 장치명(persistent device name)을 이용하면 해결 할 수 있다.
udev는 기동 또는 장치를 인식할 때마다 기기에 설치된 장치 구성이 변화해도, 안 바뀌거나 잘 변하지 않는 장치명을 /dev/disk 아래에 자동으로 작성한다.
영구 장치명은 /dev/disk/by-path/ 디렉토리 아래에 존재하는, 디스크가 설치된 경로 위치 같은 정보를 바탕으로 만들어진 디바이스 파일이 있다.
'Linux' 카테고리의 다른 글
리눅스 스터디 #8 메모리 계층 (0) | 2025.01.19 |
---|---|
리눅스 스터디 #7 파일 시스템 (0) | 2025.01.19 |
리눅스 스터디 #5 프로세스 관리(응용) (0) | 2025.01.19 |
리눅스 스터디 #4 메모리 관리 시스템 (0) | 2025.01.19 |
리눅스 스터디 #3 프로세스 스케줄러 (0) | 2025.01.19 |