그림으로 배우는 Linux 구조 - 6 / 장치접근
By Bys on June 17, 2024
장치 관리
프로세스는 장치에 직접 접근하지 못합니다. 접근하지 못하는 이유는 다음과 같습니다.
- 여러 프로그램이 동시에 장치를 조작하면 예상할 수 없는 방식으로 작동할 위험성이 있습니다.
- 원래라면 접근해서는 안되는 데이터를 훼손하거나 훔쳐 볼 위험성이 있습니다.
따라서 프로레스 대신해서 커널이 장치에 접근합니다. 구체적으로는 다음과 같은 인터페이스를 사용합니다.
- 디바이스 파일이라는 특수한 파일을 조작합니다.
- 블록 장치에 구축한 파일 시스템을 조작합니다.
- 네트워크 인터페이스 카드(NIC)는 속도 등의 문제로 디바이스 파일을 사용하는 대신에 소켓 구조를 사용합니다.
디바이스 파일
디바이스 파일은 장치마다 존재합니다. 예를 들어 저장 장치라면 /dev/sda나 /dev/sdb 같은 파일이 디바이스 파일입니다. 리눅스는 프로세스가 디바이스 파일을 조작하면 커널 내부의 디바이스 드라이버라고 부르는 소프트웨어가 사용자 대신에 장치에 접근합니다. 장치 0과 장치 1에 각각 /dev/AAA, /dev/BBB라는 디바이스 파일이 존재한다면 아래 그림 처럼 됩니다.
프로세스는 일반 파일과 똑같은 방식으로 디바이스 파일을 조작할 수 있습니다. 즉 open()이나 read(), write() 같은 시스템 콜을 호출해서 각각의 장치에 접근합니다. 장치 고유의 복잡한 조작은 ioctl() 시스템 콜을 사용합니다. 디바이스 파일에 접근할 수 있는 건 보통 루트뿐입니다.
디바이스 파일에는 다음과 같은 정보가 저장됩니다.
- 파일 종류: 캐릭터 장치(Character device) 또는 블록 장치(Block device)
- 디바이스 메이저 번호, 마이너 번호: 메이저 번호와 마이너 번호 조합이 같다면 동일한 장치에 해당하고, 그렇지 않으면 다른 장치라고 기억하면 됩니다.
디바이스 파일은 보통 /dev/ 디렉토리 아래에 존재합니다.
$ ls -l /dev
total 0
......
crw-rw-rw- 1 root root 1, 3 Jun 25 02:25 null
crw------- 1 root root 242, 0 Jun 25 02:25 nvme0
brw-rw---- 1 root disk 259, 0 Jun 25 02:25 nvme0n1
brw-rw---- 1 root disk 259, 1 Jun 25 02:25 nvme0n1p1
brw-rw---- 1 root disk 259, 2 Jun 25 02:25 nvme0n1p14
brw-rw---- 1 root disk 259, 3 Jun 25 02:25 nvme0n1p15
......
crw-rw-rw- 1 root tty 5, 0 Jun 25 02:25 tty
crw--w---- 1 root tty 4, 0 Jun 25 02:25 tty0
각 줄의 첫 글자가 c라면 캐릭터 장치, b라면 블록 장치입니다. 다섯 번째 필드가 메이저 번호, 여섯 번째 필드가 마이너 번호입니다. 따라서 /dev/tty는 캐릭터 장치, /dev/nvme0n1은 블록 장치 입니다.
캐릭터 장치
캐릭터 장치(Character device)는 읽고 쓰기는 가능하지만, 장치 내부에서 접근할 장소를 변경하는 탐색 조작이 불가능합니다. 다음은 대표적인 캐릭터 장치입니다.
- 단말
- 키보드
- 마우스
예를 들어 단말의 디바이스 파일은 다음과 같이 조작합니다.
- write() 시스템 콜: 단말에 데이터를 출력
- read() 시스템 콜: 단말에 데이터를 입력
단말 장치용 디바이스 파일에 접근해서 단말 장치를 조작해 봅시다. 우선 현재 프로세스에 대응하는 단말과 대응하는 디바이스 파일을 찾습니다.
$ ps ax | grep bash
2518 pts/0 Ss+ 0:00 /home/ubuntu/.c9/bin/tmux -u2 -L cloud92.2 new -s cloud9_terminal_487 export ISOUTPUTPANE=0;bash -l ; set -q -g status off ; set -q destroy-unattached off ; set -q mouse-select-pane on ; set -q set-titles on ; set -q quiet on ; set -q -g prefix C-b ; set -q -g default-terminal xterm-256color ; setw -q -g xterm-keys on
이 파일에 적당한 문자열을 써봅시다.
$ echo hello > /dev/pts/0
hello
단말 장치에 hello 문자열을 쓰면(디바이스 파일에 write() 시스템 콜을 호출) 단말에 문자열이 출력됩니다. 이것은 echo hello 명령어를 실행했을 때와 동일한 결과입니다. 그 이유는 echo 명령어는 표준 출력에 hello를 쓰고, 리눅스에서 표준 출력은 단말과 연결되어 있기 때문입니다.
다른 두 번째 단말을 열어 다음 커맨드를 수행하면 해당 단말에는 출력되지 않지만 Cloud 9으로 접속한 단말에서 hello가 출력됩니다.
$ echo hello > /dev/pts/0
블록 장치
블록 장치(block device)는 파일 읽기 쓰기뿐만 아니라 탐색도 가능합니다. 대표적인 블록 장치는 하드 디스크(HDD)나 SDD 같은 저장 장치입니다. 블록 장치에 데이터를 읽고 쓰면 일반 파일처럼 저장 장치 특정 위치에 있는 데이터에 접근할 수 있습니다.
사용자가 블록 디바이스 파일을 직접 조작하는 건 극히 드문 일로, 보통은 파일 시스템을 경유해서 데이터를 읽고 씁니다. 하지만 이번 실습에서는 블록 디바이스 파일에 작성한 ext4 파일 시스템 내용을 파일 시스템을 거치지 않고 블록 디바이스 파일을 조작해서 변경합니다.
우선 적당히 비어 있는 파티션을 찾습니다. 비어 있는 파티션이 없다면 나중에 설명하는 루프장치 컬럼을 참조해서 루프 장치를 사용합니다.
- Cloud9에 추가 볼륨 할당
$ sudo mkfs.ext4 nvme1n1
$ sudo mount /dev/nvme1n1 /mnt/
$ sudo echo "hello world" > /mnt/testfile
$ ls /mnt/
lost+found testfile
$ cat /mnt/testfile
hello world
$ umount /mnt/
$ strings -t x /dev/nvme1n1
42c Kt{f
488 /mnt
2423020 lost+found
2423034 testfile
2425ffc )%.`
2426ffc )%.`
2427ffc )%.`
8600000 hello world
108002108 Nt{f
108004034 f{tS8
108005018 pZHF
108008034 f{tY
10800b034 f{t_
108010020 lost+found
108010034 testfile
10801142c Kt{fKt{f
108011488 /mnt
108013038 &Z|q
출력 결과에서 /dev/nvme1n1에는 다음과 같은 정보가 들어 있다는 걸 확인할 수 있습니다.
- lost+found 디렉토리 및 testfile 파일명
- 파일 내부에 있는 hello world 문자열
각각의 문자열이 두 번 출력된 건 ext4의 저널링(journaling)기능 때문인데 저널링은 데이터를 쓰기전에 저널 영역이라고 부르는 장소에 함께 기록합니다. 따라서 같은 문자열이 두 번 등장합니다.
이번에는 testfile 내용을 블록 장치에서 변경해 봅시다.
$ cat /mnt/testfile
hello world
$ umount /mnt/
$ sudo strings -t x /dev/nvme1n1
42c Kt{f
488 /mnt
2423020 lost+found
2423034 testfile
2425ffc )%.`
2426ffc )%.`
2427ffc )%.`
8600000 hello world
$ echo "HELLO WORLD" > testfile-overwrite
$ cat testfile-overwrite
HELLO WORLD
$ dd if=testfile-overwrite of=/dev/nvme1n1 seek=$((0x8600000)) bs=1
12+0 records in
12+0 records out
12 bytes copied, 0.00357256 s, 3.4 kB/s
$ mount /dev/nvme1n1 /mnt/
$ ls /mnt/
lost+found testfile
$ cat /mnt/testfile
HELLO WORLD
테스트 파일 내용이 변경된 것을 확인할 수 있다.
디바이스 드라이버
장치를 직접 조작하려면 각 장치에 내장된 레지스터 영역을 읽고 써야 합니다. 구체적인 레지스터 종류와 조작법 같은 정보는 각 장치 사양에 따라 달라집니다. 디바이스 레지스터는 CPU 레지스터와 이름은 같지만 완전히 다른 물건입니다.
프로세스 입장에서 보는 장치 조작은 다음과 같습니다.
- 프로세스가 디바이스 파일을 사용해서 디바이스 드라이버에 장치를 조작하고 싶다고 요청합니다.
- CPU가 커널 모드로 전환되고 디바이스 드라이버가 레지스터를 사용해서 장치에 요청을 전달합니다.
- 장치가 요청에 따라 처리합니다.
- 디바이스 드라이버가 장치의 처리 완료를 확인하고 결과를 받습니다.
- CPU가 사용자 모드로 전환되고 프로세스가 디바이스 드라이버 처리 완료를 확인해서 결과를 받습니다.
메모리 맵 입출력(MMIO)
현대적인 장치는 메모리 맵 입출력(Memory-mapped I/O) 구조를 사용해서 디바이스 레지스터에 접근합니다. x86_64 아키텍처는 리눅스 커널이 자신의 가상 주소 공간에 물리 메모리를 모두 매핑합니다. 커널의 가상 주소 공간 범위가 0 ~ 1000 바이트라고 하면, 예를 들어 아래 처럼 가상 주소 공간의 0 ~ 500 물리 메모리를 매핑합니다.
MMIO 로 장치를 조작한다면 주소 공간에 메모리뿐만 아니라 레지스터도 매핑합니다. 예를 들어 장치 0~2가 존재하는 시스템이면 아래 처럼 됩니다.
- 디바이스 드라이버가 저장 장치의 데이터를 메모리의 어디로 가져올지 지정합니다.
- 메모리 주소 500(레지스터 오프셋 0)에 읽은 데이터를 저장할 주소 100을 기롭합니다.
- 메모리 주소 510(레지스터 오프셋 10)에 저장 장치 내부의 읽을 주소 300을 기록합니다.
- 메모리 주소 520(레지스터 오프셋 20)에 읽을 데이터 크기 100을 기록합니다.
- 디바이스 드라이버가 메모리 주소 530(레지스터 오프셋 30)에 읽기 요청을 뜩샇는 0을 기록합니다.
- 장치가 메모리 주소 540(레지스터 오프셋 40)에 요청 처리 중 상태를 뜻하는 0을 기록합니다.
- 장치가 장치의 주소 300~400 영역에 있는 데이터를 메모리 주소 100 이후로 전송합니다.
- 장치가 요청된 처리를 완료했다는 표시로 메모리 주소 540(레지스터 오프셋 40)에 값을 1로 변경합니다.
- 디바이스 드라이버가 요청된 처리 완료를 확인합니다.
3에서 처리 완료를 확인하려면 Polling 또는 Interrupt 라고 하는 두 가지 방법 중 하나를 사용합니다.
폴링
폴링(Polling)은 디바이스 드라이버가 능동적으로 장치에서 처리를 완료했는지 확인합니다. 장치는 디바이스 드라이버가 요청한 처리를 완료하면 처리 완료 통지용 레지스터의 값을 변화시킵니다. 디바이스 드라이버는 이 값을 주기적으로 읽어서 처리 완료를 확인합니다. 여러분이 스마트 폰에서 채팅 앱을 실행해서 상대방에게 질문하는 경우를 예로 들면 폴링은 여러분이 주기적으로 앱을 열어서 답변이 왔는지 확인하는 행위에 해당합니다.
가장 단순한 폴링은 디바이스 드라이버가 장치에 처리를 요청하고 처리 완료할 때까지 확인용 레지스터를 계속 확인하는 것입니다. 두 개의 프로세스 p0, p1이 존재할 때 p0이 디바이스 드라이버에 처리를 요청했고 디바이스 드라이버가 정기적으로 실행되서 장치 처리 완료를 기다리는 동작 흐름은 <단순한 폴링="">과 같습니다.단순한>
이때 장치에서 처리가 끝나서 디바이스 드라이버가 완료를 확인할 때 가지 CPU는 확인 작업 외에는 다른 일을 할 수 없습니다. p0은 장치 요청이 끝나기 전에는 다음 처리로 진행할 수 없으니 큰 문제가 없지만, 장치 처리와 관계없는 p1도 동작하지 못하는 건 CPU 자원 낭비 입니다.
장치에 처리를 요청하고 완료할 때까지 필요한 시간은 밀리초, 마이크로초 단위입니다. CPU 명령 하나를 실행하는데 걸리는 시간은 나노초 단위 또는 훨씬 더 작은 시간이라는 걸 생각하면 이게 얼마나 큰 낭비인지 알 수 있습니다.
따라서 이런 낭비를 줄이기 위해 계속해서 장치 처리 완료를 확인하는 대신에 일정 간격을 두고 레지스터 값을 확인하는 폴링 방법이 있습니다.
이렇게 정교하게 만들어도 폴링은 디바이스 드라이버가 복잡해진다는 단점이 있습니다.
인터럽트(Interrupt)
인터럽트는 다음과 같은 방식으로 장치가 처리를 완료했는지 확인합니다.
- 디바이스 드라이버가 장치에 처리를 요청합니다. 이후 CPU는 다른 처리를 실행합니다.
- 장치가 처리를 완료하면 인터럽트 방식으로 CPU에 알립니다.
- CPU는 미리 디바이스 드라이버가 인터럽트 컨트롤러(interrupt controller) 하드웨어에 등록해둔 인터럽트 핸들러(interrupt handler) 처리를 호출합니다.
- 인터럽트 핸들러가 장치의 처리 결과를 받습니다.
여기서 중요한 부분은 다음과 같습니다.
- 장치 처리가 완료할 때까지 CPU는 다른 프로세스를 실행할 수 있습니다. 예제에서는 p1이 동작합니다.
- 장치 처리 완료를 즉시 확인 가능합니다. 예제에서는 처리 완료 후 곧바로 p0이 동작할 수 있습니다.
- 장치에서 처리가 이뤄지는 동안 동작하는 프로세스(p1)는 장치에서 무슨 일이 일어나고 있는지 신경 쓸 필요가 없습니다.
이런 장점 때문에 폴링보다 다루기 쉬운 인터럽트를 장치 처리 완료 확인 방법으로 사용하는 경우가 많습니다.
시스템 시작 이후 지금까지 발생한 인터럽트 개수는 /proc/interrupts 파일을 보면 알 수 있습니다.
[root@ip-10-20-2-177 ~]# cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
1: 0 0 0 0 IO-APIC 1-edge i8042
4: 0 0 0 72 IO-APIC 4-edge ttyS0
8: 0 0 0 0 IO-APIC 8-edge rtc0
9: 0 0 0 0 IO-APIC 9-fasteoi acpi
12: 1 0 0 0 IO-APIC 12-edge i8042
24: 0 0 14 0 PCI-MSI 65536-edge nvme0q0
25: 0 0 414041 0 PCI-MSI 65537-edge nvme0q1
26: 0 0 0 432135 PCI-MSI 65538-edge nvme0q2
27: 25349 19952 18083 13320 PCI-MSI 81920-edge ena-mgmnt@pci:0000:00:05.0
28: 94119 101285 66607 97006 PCI-MSI 81921-edge ens5-Tx-Rx-0
29: 56864 37079 87277 125408 PCI-MSI 81922-edge ens5-Tx-Rx-1
30: 98733 100446 55174 46644 PCI-MSI 81923-edge ens5-Tx-Rx-2
31: 83165 39901 47825 121234 PCI-MSI 81924-edge ens5-Tx-Rx-3
NMI: 0 0 0 0 Non-maskable interrupts
LOC: 5663259 5856216 5608036 5868271 Local timer interrupts
SPU: 0 0 0 0 Spurious interrupts
PMI: 0 0 0 0 Performance monitoring interrupts
IWI: 342040 333762 344965 341094 IRQ work interrupts
RTR: 0 0 0 0 APIC ICR read retries
RES: 212248 218936 220447 218693 Rescheduling interrupts
CAL: 3124432 3163881 2930175 2952782 Function call interrupts
TLB: 11551 11522 11039 10250 TLB shootdowns
TRM: 0 0 0 0 Thermal event interrupts
THR: 0 0 0 0 Threshold APIC interrupts
DFR: 0 0 0 0 Deferred Error APIC interrupts
MCE: 0 0 0 0 Machine check exceptions
MCP: 254 254 254 254 Machine check polls
ERR: 0
MIS: 0
PIN: 0 0 0 0 Posted-interrupt notification event
NPI: 0 0 0 0 Nested posted-interrupt event
PIW: 0 0 0 0 Posted-interrupt wakeup event
인터럽트 컨틀롤러는 여러 인터럽트 요청(Interrupt ReQuest, IRQ)를 다루는데, 요청마다 서로 다른 인터럽트 핸들러를 등록할 수 있습니다. 각각의 요청에 IRQ 번호를 할당하는데 이 번호가 식별자 입니다.
출력 결과에서 하나의 줄이 하나의 IRQ 번호에 해당합니다. 대략 기기 하나당 IRQ 번호가 하나씩 대응한다고 생각하면 됩니다. 여기서 중요한 필드와 그 의미를 설명합니다.
- 첫 번째 필드: IRQ 번호에 해당합니다. 숫자가 아닌 줄이 있지만 여기서는 무시해도 됩니다.
- 두 번째 ~ 아홉 번째 필드 (논리 CPU 숫자만큼 필드가 존재): IRQ 번호에 대응하는 인터럽트가 각 논리 CPU에서 발생한 횟수
커널에서 일정 시간 후에 인터럽트를 발생시킬 때 사용하는 타이머 인터럽트의 발생 횟수를 1초 마다 출력해 봅니다. (이런 인터럽트는 첫 번째 필드값이 LOC: 입니다.)
[root@ip-10-20-2-177 ~]# while true; do grep Local /proc/interrupts; sleep 1; done
LOC: 5691074 5884964 5637356 5898409 Local timer interrupts
LOC: 5691178 5885101 5637580 5898539 Local timer interrupts
LOC: 5691254 5885271 5637673 5898656 Local timer interrupts
LOC: 5691382 5885398 5637770 5898774 Local timer interrupts
LOC: 5691801 5885726 5637935 5899065 Local timer interrupts
LOC: 5691905 5885873 5638036 5899278 Local timer interrupts
LOC: 5691982 5885970 5638148 5899437 Local timer interrupts
LOC: 5692110 5886178 5638247 5899565 Local timer interrupts
LOC: 5692191 5886374 5638327 5899762 Local timer interrupts
LOC: 5692230 5886473 5638464 5899841 Local timer interrupts
LOC: 5692308 5886526 5638495 5899965 Local timer interrupts
LOC: 5692463 5886586 5638568 5900061 Local timer interrupts
LOC: 5692553 5886681 5638674 5900110 Local timer interrupts
점점 횟수가 늘어나는 걸 알 수 있습니다. 예전에는 이런 인터럽트가 모든 논리 CPU 1초 동안 1000회 처럼 정기적으로 발생했지만 요즘은 필요할 때만 타이머 인터럽트를 발생시킵니다.
일부러 폴링을 사용하는 경우
장치 처리가 빠르고 처리 빈도가 높다면 예외적으로 폴링을 사용하기도 합니다. 그 이유는 인터럽트 핸들러 호출은 어느 이상의 오버헤드가 발생하고, 장치 처리가 너무 빠르면 인터럽트 핸들러를 호출하는 사이에 차례차례로 인터럽트가 계속 발생해서 처리를 따라잡지 못할 위험이 있기 때문입니다.
디바이스 파일명은 바뀌기 마련
같은 종류의 장치를 여러 개 연결한 경우라면 디바이스 파일명을 조심해서 다뤄야 합니다. 여기서는 저장 장치에 한정해서 설명합니다.
여러 장치가 연결되어 있다면 커널은 일정한 규칙에 따라 각각 다른 이름으로 디바이스 파일(정확하게는 메이저 번호와 마이너 번호 조합)에 대응시킵니다. SATA나 SAS라면 /dev/sda, /dev/sdb, /dev/sdc, ……, NVMe SSD라면 /dev/nvme0n1, /dev/nvme1n1, /dev/nvme2n1, ……이런 식이 됩니다.
특징 | SATA | SAS | NVMe |
---|---|---|---|
성능 | 낮음 | 중간 | 높음 |
가격 | 저렴 | 중간 | 비쌈 |
용도 | 일반 데스크탑, 노트북 | 서버, 고성능 스토리지 | 고성능 SSD |
프로토콜 | 상대적으로 간단 | 복잡 | 최적화된 프로토콜 |
지연 시간 | 높음 | 중간 | 낮음 |
병렬 처리 | 제한적 | 가능 | 뛰어남 |
주의할 점은 이런 대응 관계는 PC를 기동할 때마다 바뀐다는 점입니다. 예를 들어 SATA 접속 방식의 저장 장치 A, B를 연결하는 경우를 생각해 봅시다. 이때 무엇이 /dev/sda가 되고 /dev/sdb가 될지는 장치 인식 순서에 달려 있습니다. 커널에서 저장 장치를 인식하는데 A가 먼저 그리고 B 순서였다면 인식한 순서대로 /dev/sda, /dev/sdb라고 이름이 붙습니다.
이후에 재시작했을 때 어떤 이유로 저장 장치 인식 순서가 바뀌게 되면 서로 장치명이 바뀝니다. 순서가 바뀌는 데에는 다음과 같은 이유가 있습니다.
이렇게 이름이 바뀌면 어떤 일이 일어날까요? 운이 좋으면 부팅에 실패하는 정도로 끝나지만 운이 나쁘면 데이터가 파괴됩니다.
이런 문제는 systemd의 udev 프로그램이 만드는 영구 장치명(persistent device name)을 이용하면 해결 할 수 있습니다. udev는 기동 또는 장치를 인식할 때마다 기기에 설치된 장치 구성이 변화해도, 안 바뀌거나 잘 변하지 않는 장치명을 /dev/disk 아래에 자동으로 작성합니다.
영구 장치명은 /dev/disk/by-path/ 디렉토리 아래에 존재하는, 디스크가 설치된 경로 위치같은 정보를 바탕으로 만들어진 디바이스 파일이 있습니다.
$ ls -l /dev/nvme0n1
brw-rw----. 1 root disk 259, 0 Oct 18 10:02 /dev/nvme0n1
$ ll /dev/disk/by-path
total 0
lrwxrwxrwx. 1 root root 13 Oct 18 10:02 pci-0000:00:04.0-nvme-1 -> ../../nvme0n1
lrwxrwxrwx. 1 root root 15 Oct 18 10:02 pci-0000:00:04.0-nvme-1-part1 -> ../../nvme0n1p1
lrwxrwxrwx. 1 root root 17 Oct 18 10:02 pci-0000:00:04.0-nvme-1-part127 -> ../../nvme0n1p127
lrwxrwxrwx. 1 root root 17 Oct 18 10:02 pci-0000:00:04.0-nvme-1-part128 -> ../../nvme0n1p128
이외에도 파일 시스템에 레이블이나 UUID가 있으면 udev는 대응하는 장치에 /dev/disk/by-label/ 디렉토리, /dev/disk/by-uuid/ 디렉토리 아래에 파일을 만듭니다. 단순히 마운트할 파일 시스템의 실수를 방지할 목적이라면 mount 명령어에 레이블이나 UUID 를 지정해서 문제 발생을 막을 수 있습니다.
예를 들어, 시스템 시작할 때 자동으로 마운트하는 파일 시스템을 설정하는 /etc/fstab 파일에는 /dev/sda 같은 커널이 붙인 이름이 아니라 UUID로 장치를 저장했습니다.
$ cat /etc/fstab
#
UUID=2277f5ea-ebeb-42da-a2e1-3b9cf1c1bca9 / xfs defaults,noatime 1 1
UUID=E239-DD44 /boot/efi vfat defaults,noatime,uid=0,gid=0,umask=0077,shortname=winnt,x-systemd.automount 0 2
따라서, UUID=2277f5ea-ebeb-42da-a2e1-3b9cf1c1bca9에 대응하는 장치를 커널에서 /dev/sda 또는 /dev/sdb라고 이름 붙이든지, 문제없이 마운트할 수 있습니다.
Reference
- 그림으로 배우는 리눅스 구조 (다케우치 사토루)
- E-Book
book
linux
device
]