본문으로 이동
  • 현재 모든 문서는 작성 중입니다. 보시는 데 불편함이 있더라도 양해 부탁드립니다.

운영체제 기본/보호: 두 판 사이의 차이

~ldmsys
출처·문체 정리: 커널 원문은 git.kernel.org, em dash는 콜론으로 변경
보호 필요성: RV64 보드의 GPIO 전원 제어 예시로 MMIO 접근의 시스템 전체 영향 설명
 
(같은 사용자의 중간 판 16개는 보이지 않습니다)
1번째 줄: 1번째 줄:
[[운영체제 기본|목차]] · 이전: [[운영체제 기본/추상화|추상화]] · 다음: [[운영체제 기본/프로세스와 스레드|프로세스와 스레드]]
{{이전 다음 문서
|이전=← 이전 문서<br />[[운영체제 기본/추상화|추상화]]
|목차=[[운영체제 기본#총론|총론 목차]]
|다음=다음 문서 →<br />[[운영체제 기본/장치 제어와 입출력|장치 제어와 입출력]]
}}


여러 프로그램이 한 컴퓨터에서 실행된다면, 한 프로그램의 실수가 다른 프로그램이나 커널까지 망가뜨리지 않도록 경계를 두어야 한다. Linux에서는 CPU의 권한 모드와 메모리 접근 권한 등을 이용하고, 커널이 자원 접근 요청을 검사한다.
== 프로그램 오류와 보호의 필요성 ==
RV64 CPU를 사용하는 어떤 보드에서 '''GPIO (General-Purpose Input/Output, 범용 입출력)'''의 출력선 하나가 전원 제어 회로에 연결되어 있다고 하자. 이 보드는 그 선의 값을 0으로 바꾸면 전원이 끊기도록 설계되어 있다. GPIO 제어 레지스터는 주소 공간에 배치되어 있어, 해당 주소에 값을 쓰면 RAM의 데이터가 아니라 장치의 상태가 바뀐다. 이런 방식을 '''메모리 매핑 입출력(Memory-Mapped I/O, MMIO)'''이라고 한다. GPIO를 이용한 시스템 전원 제어는 실제로도 쓰인다.<ref name="gpio-poweroff">Linux 커널 문서, [https://cdn.kernel.org/doc/html/latest/driver-api/gpio/drivers-on-gpio.html Subsystem drivers using GPIO], poweroff-gpio.</ref>


== 사용자 공간과 커널 공간 ==
프로그램이 그 레지스터에 제한 없이 접근할 수 있는 환경이라면 다음 쓰기 한 번으로 기기의 전원을 끊을 수 있다. <code>POWER_GPIO_OUTPUT</code>은 이 예의 보드에서 정한 레지스터 주소를 뜻한다. GPIO의 주소와 전원 회로 연결은 RV64 명령어 집합이 아니라 보드 설계가 정한다.
일상적으로 Linux라고 부르는 시스템에는 커널뿐 아니라 셸, C 라이브러리, 각종 명령어와 서비스가 함께 들어 있다. 이 중 '''커널'''은 CPU 실행, 메모리 매핑, 장치 접근 등을 관리하는 핵심 부분이다. Bash나 <code>ls</code>는 커널 바깥의 사용자 공간에서 실행되는 프로그램이다.


프로그램이 정수 두 개를 더할 때마다 커널을 호출하는 것은 아니다. 일반적인 계산과 접근이 허용된 메모리의 읽기·쓰기는 사용자 공간에서 진행된다. 파일을 열거나 새 프로세스를 만드는 등 커널의 도움이 필요한 작업에는 '''시스템 호출'''을 사용한다. C에서는 보통 라이브러리 함수를 통해 이 인터페이스를 사용한다. 따라서 C 함수 호출 하나와 시스템 호출 하나가 반드시 일대일로 대응하지는 않는다. [https://man7.org/linux/man-pages/man2/syscalls.2.html syscalls(2)]
<pre>
#include <stdint.h>


void mistaken_write(void)
{
    volatile uint32_t *power_gpio =
        (volatile uint32_t *)(uintptr_t)POWER_GPIO_OUTPUT;


    *power_gpio = 0;
}
</pre>


사용자 모드의 프로그램은 임의의 커널 메모리를 읽고 쓰거나, 제한된 CPU 명령을 마음대로 실행할 수 없다. 그렇다고 아무 일도 못 하는 것은 아니다. 일반적인 계산은 CPU에서 직접 실행하며, 커널의 권한이 필요한 작업만 정해진 경로로 요청한다.
<pre>
프로그램의 메모리 쓰기
        |
        v
GPIO 제어 레지스터 → 출력선 변경 → 전원 제어 회로 → 기기 전원 차단
</pre>


== 시스템 호출은 권한을 넘겨주는 통로가 아니다 ==
프로그램 입장에서는 평범한 메모리 쓰기지만, 결과는 변수 하나의 변경으로 끝나지 않는다. 저장 중이던 데이터가 손실되거나, 전원 순서와 전압을 제어하는 회로에서는 잘못된 조작이 장치 손상으로 이어질 수도 있다. 결과는 연결된 회로에 달려 있다. 장치뿐 아니라 운영체제 코드가 있는 RAM을 덮어써도 시스템 전체가 멈출 수 있다. '''프로그램의 실수가 다른 프로그램과 기기 전체의 피해로 번지지 않게 하는 것'''이 보호가 필요한 이유다.
파일을 열 때 프로그램은 경로와 열기 방식을 전달한다. 커널은 경로를 해석하고 접근 조건을 검사한 다음 성공하면 파일 디스크립터를 반환한다. 실패하면 프로그램은 오류를 처리해야 한다. 호출한 프로그램이 잠시 커널의 모든 권한을 얻어 원하는 일을 하는 구조가 아니다.


C에서 <code>open()</code>이 -1을 반환하면 <code>errno</code>를 확인한다. 예를 들어 <code>ENOENT</code>는 경로의 구성 요소가 없거나 끊어진 심볼릭 링크 등으로 대상을 찾지 못한 경우에 나올 수 있고, <code>EACCES</code>는 필요한 접근 권한이 없는 경우에 나올 수 있다. “열기에 실패했다”는 결과만 보고 파일이 없다고 단정하면 원인을 잘못 찾게 된다. [https://man7.org/linux/man-pages/man2/open.2.html open(2)]
'''보호'''는 이런 접근이 대상에 도달하기 전에 프로그램의 실행·접근 경계를 강제하는 일이다. 운영체제는 일반 프로그램이 장치 레지스터나 커널 메모리를 직접 쓰지 못하게 설정하고, CPU는 실행 중 그 설정을 검사한다. 프로그램은 장치를 관리하는 커널 코드에 작업을 요청해야 한다. RISC-V에서는 페이지 단위 접근 권한과 물리 메모리 보호(PMP) 같은 하드웨어 수단으로 접근을 제한할 수 있다. 단순히 페이징을 끈다고 모든 보호가 사라지는 것은 아니다.<ref name="riscv-pmp">RISC-V, [https://docs.riscv.org/reference/isa/priv/machine.html Machine-Level ISA], Physical Memory Protection.</ref>


같은 이유로 읽기 전용으로 연 fd에 쓰기를 요청하면 거부된다. 메모리에도 비슷한 제한이 있다. 매핑되지 않은 주소나 허용되지 않은 쓰기 접근은 정상적인 메모리 사용으로 처리되지 않는다. 사용자와 그룹에 따른 파일 권한 검사, CPU 권한 모드, 페이지 접근 권한은 서로 관련되지만 동일한 장치는 아니다.
보호 규칙 자체도 보호해야 한다. 프로그램이 자신의 접근 권한을 기록한 메모리를 마음대로 수정할 수 있다면 권한 검사는 의미가 없어진다. 규칙을 집행하는 코드와 상태를 보호 대상과 분리해야 한다.<ref name="source-1">[https://web.mit.edu/saltzer/www/publications/protection/Basic.html Jerome H. Saltzer·Michael D. Schroeder, The Protection of Information in Computer Systems (1975), §I].</ref>


== 커널 진입과 문맥 교환 ==
== 실행 권한, 주소, 자원의 경계 ==
시스템 호출로 커널 코드를 실행하는 것과 다른 스레드에 CPU를 넘기는 것은 구별해야 한다. 요청을 바로 처리할 수 있다면 같은 스레드가 커널 작업을 마치고 사용자 공간으로 돌아갈 수 있다. 반면 데이터가 도착할 때까지 기다려야 하는 호출이라면 해당 스레드가 잠들고 다른 스레드가 실행될 수 있다.
커널(Kernel)은 CPU·메모리·장치의 사용을 관리하고 프로그램의 요청을 처리하는 운영체제의 핵심 코드다. 커널 코드와 그 관리 정보도 메모리에 있으므로 일반 프로그램이 마음대로 수정하지 못하게 해야 한다.


이 차이는 시스템 호출 비용을 이야기할 때도 중요하다. 호출 횟수만으로 문맥 교환 횟수를 추정할 수 없으며, 호출에 걸린 시간에는 커널 처리뿐 아니라 대기 시간이 포함될 수도 있다.
일반 프로그램의 명령은 CPU에서 직접 실행된다. 운영체제가 그 사이에 끼어들어 명령 하나, 메모리 접근 하나마다 소프트웨어로 권한을 검사하면 실행 비용이 지나치게 커진다. 따라서 '''운영체제가 허용 범위와 권한을 설정하고, CPU가 실행 중 그 규칙을 검사하도록 하드웨어의 도움을 받는다.''' 정상적인 연산은 직접 실행하고, 허용되지 않거나 추가 처리가 필요한 동작에서만 커널로 제어를 넘긴다.<ref name="source-2">[https://mit-pdos.github.io/xv6-riscv-book/first.html MIT xv6: Operating system organization], 사용자 모드·커널 모드와 하드웨어 격리.</ref>
 
CPU의 '''권한 모드 (Privilege Mode)'''는 일반 프로그램과 커널이 실행할 수 있는 명령 및 접근 가능한 제어 상태를 구분한다. x86-64에서는 보통 사용자 프로그램을 현재 특권 수준(Current Privilege Level, CPL) 3, 커널을 CPL 0에서 실행한다. RISC-V RV64의 일반적인 운영체제 구성에서는 사용자 프로그램이 U-mode, 커널이 S-mode를 사용한다. 이름과 단계는 다르지만, 사용자 코드가 시스템 전체의 규칙을 임의로 바꾸지 못하게 한다는 목적은 같다.
 
메모리 접근에서는 '''MMU (Memory Management Unit, 메모리 관리 장치)'''가 운영체제를 돕는다. MMU는 가상 주소를 물리 주소로 바꾸고 접근 권한을 검사하는 하드웨어다. 어느 주소를 연결하고 읽기·쓰기·실행을 어디까지 허용할지는 운영체제가 주소 연결표인 페이지 테이블에 설정한다. MMU는 그 설정을 메모리 접근마다 적용한다. '''정책과 설정은 운영체제가 맡고, 반복되는 주소 변환과 검사는 하드웨어가 맡는 분업'''이다. 프로그램이 다른 주소를 계산해도 허용되지 않은 접근까지 가능해지는 것은 아니다.<ref name="page-protection">[https://docs.kernel.org/mm/page_tables.html Linux 커널 문서: Page Tables], 주소 변환과 페이지 접근 권한.</ref> 페이지 테이블과 변환 결과를 캐시하는 TLB는 [[운영체제 기본/가상 메모리#페이지 테이블과 주소 변환|가상 메모리]]에서 이어서 설명한다.
 
검사에 쓰이는 설정도 보호해야 한다. 사용자 프로그램이 페이지 테이블을 바꾸거나 다른 페이지 테이블을 선택할 수 있다면 MMU가 적용하는 규칙 자체를 고칠 수 있기 때문이다. 페이지 테이블을 담은 메모리의 보호와, 주소 변환을 제어하는 레지스터의 접근 제한이 함께 필요하다.
 
== 특권 명령과 장치 접근 ==
'''특권 명령 (Privileged Instruction)'''은 CPU의 현재 권한에 따라 실행이 제한되는 명령이다. 사용자 프로그램이 CPU를 멈추거나 예외 처리와 메모리 보호에 필요한 제어 상태를 마음대로 바꾸면 다른 프로그램과 커널도 영향을 받는다. CPU는 이를 실행하기 전에 필요한 권한이 있는지 검사한다.
 
* '''x86-64''': CPU를 정지 상태로 만드는 <code>HLT</code>를 CPL 3에서 실행하면 일반 보호 예외 <code>#GP</code>가 발생한다. 명령 자체가 존재하지 않는 경우의 잘못된 명령 예외 <code>#UD</code>와 구별된다.<ref name="intel-hlt">Intel, [https://cdrdv2-public.intel.com/812383/253666-sdm-vol-2a.pdf Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 2A], HLT, Protected Mode Exceptions.</ref>
* '''RISC-V RV64''': U-mode 프로그램이 <code>mstatus</code>처럼 더 높은 권한을 요구하는 제어·상태 레지스터 (Control and Status Register, CSR)에 접근하면 불법 명령 예외 (Illegal Instruction Exception)가 발생한다. 모든 CSR이 커널 전용인 것은 아니며, 레지스터마다 필요한 권한이 정해져 있다.<ref name="riscv-csr">[https://docs.riscv.org/reference/isa/priv/priv-csrs.html RISC-V Privileged Architecture: Control and Status Registers], CSR 접근 권한과 불법 명령 예외.</ref>
 
장치에 직접 명령을 내리는 경로도 제한해야 한다. x86의 포트 입출력 <code>IN</code>·<code>OUT</code>은 CPU가 I/O 권한 수준(I/O Privilege Level, IOPL)과 필요한 경우 태스크 상태 세그먼트(Task State Segment, TSS)에 연결된 포트별 허용 비트맵을 검사하며, 허용되지 않은 접근은 <code>#GP</code>로 막는다. 따라서 이 명령들이 무조건 커널 모드에서만 실행된다고 단정하기보다, '''운영체제가 설정한 I/O 권한을 CPU가 강제한다'''고 보는 것이 정확하다.<ref name="intel-exception" />
 
메모리 매핑 입출력 (Memory-Mapped I/O, MMIO)은 장치 레지스터를 주소로 접근한다. 운영체제가 사용자 주소 공간에 해당 장치 영역을 매핑하지 않거나 필요한 권한을 주지 않으면 일반 프로그램은 그 경로로 장치를 제어할 수 없다. 허용된 일부 영역만 매핑해 주는 구성도 가능하다. 포트 입출력과 MMIO의 차이는 [[운영체제 기본/장치 제어와 입출력|장치 제어와 입출력]], 구체적인 매핑은 [[운영체제 기본/Linux의 메모리 매핑/MMIO 장치 매핑|MMIO 장치 매핑]]으로 이어진다.
 
== 보호 위반과 예외 ==
CPU가 명령어를 실행하다가 별도의 처리가 필요한 조건을 발견하여 정해진 처리 코드로 제어를 넘기는 일을 '''예외 (Exception)'''라고 한다. 허용되지 않은 메모리 접근과 특권 명령 실행은 모두 실행 중인 명령에 따라 동기적으로 발생하는 예외의 원인이 될 수 있다. 장치가 현재 명령어와 무관하게 보내는 하드웨어 인터럽트와 구별된다.<ref name="intel-exception">Intel, [https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A], 예외 처리, 특권 수준 및 I/O 보호.</ref>
 
x86에서 페이지 매핑이 없거나 페이지 접근 권한을 위반하면 '''페이지 폴트 (Page Fault, #PF)'''가 발생한다. 일반 데이터 접근의 주소 형태가 x86-64의 규칙에 맞지 않는 경우나 앞에서 본 <code>HLT</code> 권한 위반은 '''일반 보호 예외 (General-Protection Exception, #GP)'''의 원인이 된다. 보호 위반이 모두 메모리 문제인 것도, 모든 메모리 문제가 #PF인 것도 아니다.<ref name="intel-exception" />
 
x86에서는 CPU가 #PF의 벡터 14 또는 #GP의 벡터 13으로 '''인터럽트 서술자 테이블 (Interrupt Descriptor Table, IDT)'''의 게이트를 선택하고 커널 진입 코드로 제어를 넘긴다. RV64에서는 IDT 대신 트랩 벡터와 원인 레지스터를 사용한다. 사용자 프로그램의 트랩을 S-mode 커널이 받는 구성에서는 <code>stvec</code>으로 진입하고 <code>scause</code> 등에서 원인을 확인한다. 구체적인 진입 방식이 달라도, 하드웨어가 원인을 알리고 운영체제가 처리하는 경계는 같다.<ref name="riscv-trap">[https://docs.riscv.org/reference/isa/priv/supervisor.html RISC-V Privileged Architecture: Supervisor-Level ISA], stvec·scause와 예외 처리.</ref>
 
<pre>
운영체제: 권한 모드·페이지 테이블·I/O 권한 설정
                        |
                        v
사용자 명령 실행 → CPU/MMU가 설정된 권한 검사
                        |
            +-----------+-----------+
            |                      |
          허용된 동작            권한 위반 등
            |                      |
          직접 실행              예외 → 커널 처리
</pre>
 
예외를 받았다고 곧바로 프로그램을 종료하지는 않는다. 유효한 주소인데 필요한 페이지가 아직 준비되지 않았거나, 쓰기 시 복사 때문에 쓰기 권한을 잠시 막아 둔 경우에는 커널이 매핑을 고치고 명령을 다시 실행할 수 있다. 자세한 복구 과정은 [[운영체제 기본/가상 메모리#페이지 폴트의 처리|페이지 폴트의 처리]]에서 다룬다.
 
반대로 허용되지 않은 사용자 접근을 복구할 수 없다면 운영체제는 프로그램에 오류를 통지한다. Linux에서는 잘못된 메모리 접근에 <code>SIGSEGV</code> 같은 '''시그널 (Signal)'''을 보낼 수 있으며, 기본 처리에 따르면 프로세스가 종료된다. CPU가 직접 시그널을 보내거나 프로그램을 죽이는 것은 아니다. 예외를 어느 시그널로 바꿀지는 아키텍처와 운영체제의 처리 규칙에 달려 있다.<ref name="signal-overview">[https://man7.org/linux/man-pages/man7/signal.7.html Linux man-pages: signal(7)], signal dispositions 및 hardware exception에서 발생하는 시그널.</ref>
 
시그널은 보호 위반 전용 통지가 아니다. 종료 요청이나 자식 프로세스의 상태 변화도 알릴 수 있다. 발생·전달·처리와 다른 통신 방식의 관계는 [[운영체제 기본/프로세스 간 통신#시그널과 사건 통지|프로세스 간 통신]]에서 설명한다. IDT를 통한 진입과 복귀는 [[운영체제 기본/장치 제어와 입출력#처리 진입점과 IDT|장치 제어와 입출력의 IDT 절]]로 이어진다.
 
== 시스템 콜과 요청 검증 ==
격리된 프로그램도 파일을 읽고 다른 프로그램과 통신해야 한다. '''시스템 콜(System Call)'''은 프로그램이 정해진 진입점을 통해 커널에 작업을 요청하는 경로다. 하드웨어가 제어를 커널로 넘기면 커널 코드가 요청을 처리하고 결과를 돌려준다. 진입·복귀 과정은 [[운영체제 기본/장치 제어와 입출력#진입, 상태 보존, 복귀|장치 제어와 입출력]]을 참조한다. 호출자는 요청할 작업과 인자를 고를 수 있지만 커널에서 실행할 임의의 명령을 지정할 수는 없다.
 
예를 들어 데이터를 읽어 달라는 요청에는 결과를 저장할 사용자 주소와 최대 길이가 들어간다. 커널은 그 주소 범위가 사용자 공간에 속하고 요청한 방향으로 접근 가능한지 확인해야 한다. 주소와 길이를 합산할 때 정수 범위를 넘는 경우도 고려해야 한다. 호출자가 넘긴 숫자나 포인터를 신뢰하면 커널이 자신의 권한으로 금지된 접근을 대신 수행할 수 있다. <ref name="source-3">[https://mit-pdos.github.io/xv6-riscv-book/trap.html xv6: Traps and system calls].</ref>
 
검사 시점과 사용 시점 사이의 변화도 문제다. 다른 스레드가 인자가 담긴 메모리를 바꿀 수 있다면, 검사한 내용과 실제 처리한 내용이 달라질 수 있다. 필요한 값을 커널의 관리 영역에 복사하거나, 대상의 수명을 유지하고 동기화하는 등 처리 과정 내내 가정이 유지되도록 만들어야 한다.
 
== 격리와 협력의 비용 ==
경계를 넘을 때는 실행 상태를 보존하고 복원하며 인자와 권한을 확인하는 비용이 든다. 데이터를 복사해야 할 수도 있다. 다만 커널에 진입했다고 반드시 다른 스레드로 컨텍스트 스위치(Context Switch)를 하는 것은 아니다. 요청을 바로 끝내면 같은 스레드가 돌아오고, 기다려야 한다면 다른 스레드에 CPU를 넘길 수 있다.
 
이미지 처리 기능을 별도 프로세스에 두면 그 기능의 잘못된 메모리 쓰기가 편집기의 메모리를 직접 훼손하는 일을 막을 수 있다. 대신 큰 이미지와 결과를 주고받는 통신 비용이 생긴다. [[운영체제 기본/프로세스 간 통신#공유 메모리와 동기화|공유 메모리]]로 복사를 줄이면 같은 영역에 대한 동기화와 접근 범위를 관리해야 한다. 경계를 나누는 단위와 데이터를 교환하는 방식은 함께 설계해야 한다.
 
하드웨어가 접근 경계를 지켜도, 쓰기가 허용된 메모리에 잘못된 계산 결과를 저장하는 것까지 막지는 않는다. 공유 영역에서 두 스레드가 같은 값을 동시에 갱신하는 문제도 남는다. 하드웨어 보호는 실행과 접근의 경계를 강제하고, 그 경계 안의 계산과 [[운영체제 기본/동시성|동기화]]는 프로그램이 올바르게 구성해야 한다.


== 참고 문헌 ==
== 참고 문헌 ==
* [https://man7.org/linux/man-pages/man2/syscalls.2.html syscalls(2)]: 커널 인터페이스와 라이브러리 래퍼.
<references />
* [https://man7.org/linux/man-pages/man2/open.2.html open(2)]: 파일 열기의 권한 검사와 오류.
 
* [https://man7.org/linux/man-pages/man2/mprotect.2.html mprotect(2)]: 메모리 영역의 접근 보호.
{{이전 다음 문서
|이전=← 이전 문서<br />[[운영체제 기본/추상화|추상화]]
|목차=[[운영체제 기본#총론|총론 목차]]
|다음=다음 문서 →<br />[[운영체제 기본/장치 제어와 입출력|장치 제어와 입출력]]
}}
 
{{운영체제 기본 둘러보기}}

2026년 11월 1일 (일) 01:10 기준 최신판

프로그램 오류와 보호의 필요성

RV64 CPU를 사용하는 어떤 보드에서 GPIO (General-Purpose Input/Output, 범용 입출력)의 출력선 하나가 전원 제어 회로에 연결되어 있다고 하자. 이 보드는 그 선의 값을 0으로 바꾸면 전원이 끊기도록 설계되어 있다. GPIO 제어 레지스터는 주소 공간에 배치되어 있어, 해당 주소에 값을 쓰면 RAM의 데이터가 아니라 장치의 상태가 바뀐다. 이런 방식을 메모리 매핑 입출력(Memory-Mapped I/O, MMIO)이라고 한다. GPIO를 이용한 시스템 전원 제어는 실제로도 쓰인다.[1]

프로그램이 그 레지스터에 제한 없이 접근할 수 있는 환경이라면 다음 쓰기 한 번으로 기기의 전원을 끊을 수 있다. POWER_GPIO_OUTPUT은 이 예의 보드에서 정한 레지스터 주소를 뜻한다. GPIO의 주소와 전원 회로 연결은 RV64 명령어 집합이 아니라 보드 설계가 정한다.

#include <stdint.h>

void mistaken_write(void)
{
    volatile uint32_t *power_gpio =
        (volatile uint32_t *)(uintptr_t)POWER_GPIO_OUTPUT;

    *power_gpio = 0;
}
프로그램의 메모리 쓰기
        |
        v
GPIO 제어 레지스터 → 출력선 변경 → 전원 제어 회로 → 기기 전원 차단

프로그램 입장에서는 평범한 메모리 쓰기지만, 결과는 변수 하나의 변경으로 끝나지 않는다. 저장 중이던 데이터가 손실되거나, 전원 순서와 전압을 제어하는 회로에서는 잘못된 조작이 장치 손상으로 이어질 수도 있다. 결과는 연결된 회로에 달려 있다. 장치뿐 아니라 운영체제 코드가 있는 RAM을 덮어써도 시스템 전체가 멈출 수 있다. 프로그램의 실수가 다른 프로그램과 기기 전체의 피해로 번지지 않게 하는 것이 보호가 필요한 이유다.

보호는 이런 접근이 대상에 도달하기 전에 프로그램의 실행·접근 경계를 강제하는 일이다. 운영체제는 일반 프로그램이 장치 레지스터나 커널 메모리를 직접 쓰지 못하게 설정하고, CPU는 실행 중 그 설정을 검사한다. 프로그램은 장치를 관리하는 커널 코드에 작업을 요청해야 한다. RISC-V에서는 페이지 단위 접근 권한과 물리 메모리 보호(PMP) 같은 하드웨어 수단으로 접근을 제한할 수 있다. 단순히 페이징을 끈다고 모든 보호가 사라지는 것은 아니다.[2]

보호 규칙 자체도 보호해야 한다. 프로그램이 자신의 접근 권한을 기록한 메모리를 마음대로 수정할 수 있다면 권한 검사는 의미가 없어진다. 규칙을 집행하는 코드와 상태를 보호 대상과 분리해야 한다.[3]

실행 권한, 주소, 자원의 경계

커널(Kernel)은 CPU·메모리·장치의 사용을 관리하고 프로그램의 요청을 처리하는 운영체제의 핵심 코드다. 커널 코드와 그 관리 정보도 메모리에 있으므로 일반 프로그램이 마음대로 수정하지 못하게 해야 한다.

일반 프로그램의 명령은 CPU에서 직접 실행된다. 운영체제가 그 사이에 끼어들어 명령 하나, 메모리 접근 하나마다 소프트웨어로 권한을 검사하면 실행 비용이 지나치게 커진다. 따라서 운영체제가 허용 범위와 권한을 설정하고, CPU가 실행 중 그 규칙을 검사하도록 하드웨어의 도움을 받는다. 정상적인 연산은 직접 실행하고, 허용되지 않거나 추가 처리가 필요한 동작에서만 커널로 제어를 넘긴다.[4]

CPU의 권한 모드 (Privilege Mode)는 일반 프로그램과 커널이 실행할 수 있는 명령 및 접근 가능한 제어 상태를 구분한다. x86-64에서는 보통 사용자 프로그램을 현재 특권 수준(Current Privilege Level, CPL) 3, 커널을 CPL 0에서 실행한다. RISC-V RV64의 일반적인 운영체제 구성에서는 사용자 프로그램이 U-mode, 커널이 S-mode를 사용한다. 이름과 단계는 다르지만, 사용자 코드가 시스템 전체의 규칙을 임의로 바꾸지 못하게 한다는 목적은 같다.

메모리 접근에서는 MMU (Memory Management Unit, 메모리 관리 장치)가 운영체제를 돕는다. MMU는 가상 주소를 물리 주소로 바꾸고 접근 권한을 검사하는 하드웨어다. 어느 주소를 연결하고 읽기·쓰기·실행을 어디까지 허용할지는 운영체제가 주소 연결표인 페이지 테이블에 설정한다. MMU는 그 설정을 메모리 접근마다 적용한다. 정책과 설정은 운영체제가 맡고, 반복되는 주소 변환과 검사는 하드웨어가 맡는 분업이다. 프로그램이 다른 주소를 계산해도 허용되지 않은 접근까지 가능해지는 것은 아니다.[5] 페이지 테이블과 변환 결과를 캐시하는 TLB는 가상 메모리에서 이어서 설명한다.

검사에 쓰이는 설정도 보호해야 한다. 사용자 프로그램이 페이지 테이블을 바꾸거나 다른 페이지 테이블을 선택할 수 있다면 MMU가 적용하는 규칙 자체를 고칠 수 있기 때문이다. 페이지 테이블을 담은 메모리의 보호와, 주소 변환을 제어하는 레지스터의 접근 제한이 함께 필요하다.

특권 명령과 장치 접근

특권 명령 (Privileged Instruction)은 CPU의 현재 권한에 따라 실행이 제한되는 명령이다. 사용자 프로그램이 CPU를 멈추거나 예외 처리와 메모리 보호에 필요한 제어 상태를 마음대로 바꾸면 다른 프로그램과 커널도 영향을 받는다. CPU는 이를 실행하기 전에 필요한 권한이 있는지 검사한다.

  • x86-64: CPU를 정지 상태로 만드는 HLT를 CPL 3에서 실행하면 일반 보호 예외 #GP가 발생한다. 명령 자체가 존재하지 않는 경우의 잘못된 명령 예외 #UD와 구별된다.[6]
  • RISC-V RV64: U-mode 프로그램이 mstatus처럼 더 높은 권한을 요구하는 제어·상태 레지스터 (Control and Status Register, CSR)에 접근하면 불법 명령 예외 (Illegal Instruction Exception)가 발생한다. 모든 CSR이 커널 전용인 것은 아니며, 레지스터마다 필요한 권한이 정해져 있다.[7]

장치에 직접 명령을 내리는 경로도 제한해야 한다. x86의 포트 입출력 IN·OUT은 CPU가 I/O 권한 수준(I/O Privilege Level, IOPL)과 필요한 경우 태스크 상태 세그먼트(Task State Segment, TSS)에 연결된 포트별 허용 비트맵을 검사하며, 허용되지 않은 접근은 #GP로 막는다. 따라서 이 명령들이 무조건 커널 모드에서만 실행된다고 단정하기보다, 운영체제가 설정한 I/O 권한을 CPU가 강제한다고 보는 것이 정확하다.[8]

메모리 매핑 입출력 (Memory-Mapped I/O, MMIO)은 장치 레지스터를 주소로 접근한다. 운영체제가 사용자 주소 공간에 해당 장치 영역을 매핑하지 않거나 필요한 권한을 주지 않으면 일반 프로그램은 그 경로로 장치를 제어할 수 없다. 허용된 일부 영역만 매핑해 주는 구성도 가능하다. 포트 입출력과 MMIO의 차이는 장치 제어와 입출력, 구체적인 매핑은 MMIO 장치 매핑으로 이어진다.

보호 위반과 예외

CPU가 명령어를 실행하다가 별도의 처리가 필요한 조건을 발견하여 정해진 처리 코드로 제어를 넘기는 일을 예외 (Exception)라고 한다. 허용되지 않은 메모리 접근과 특권 명령 실행은 모두 실행 중인 명령에 따라 동기적으로 발생하는 예외의 원인이 될 수 있다. 장치가 현재 명령어와 무관하게 보내는 하드웨어 인터럽트와 구별된다.[8]

x86에서 페이지 매핑이 없거나 페이지 접근 권한을 위반하면 페이지 폴트 (Page Fault, #PF)가 발생한다. 일반 데이터 접근의 주소 형태가 x86-64의 규칙에 맞지 않는 경우나 앞에서 본 HLT 권한 위반은 일반 보호 예외 (General-Protection Exception, #GP)의 원인이 된다. 보호 위반이 모두 메모리 문제인 것도, 모든 메모리 문제가 #PF인 것도 아니다.[8]

x86에서는 CPU가 #PF의 벡터 14 또는 #GP의 벡터 13으로 인터럽트 서술자 테이블 (Interrupt Descriptor Table, IDT)의 게이트를 선택하고 커널 진입 코드로 제어를 넘긴다. RV64에서는 IDT 대신 트랩 벡터와 원인 레지스터를 사용한다. 사용자 프로그램의 트랩을 S-mode 커널이 받는 구성에서는 stvec으로 진입하고 scause 등에서 원인을 확인한다. 구체적인 진입 방식이 달라도, 하드웨어가 원인을 알리고 운영체제가 처리하는 경계는 같다.[9]

운영체제: 권한 모드·페이지 테이블·I/O 권한 설정
                         |
                         v
사용자 명령 실행 → CPU/MMU가 설정된 권한 검사
                         |
             +-----------+-----------+
             |                       |
          허용된 동작             권한 위반 등
             |                       |
          직접 실행              예외 → 커널 처리

예외를 받았다고 곧바로 프로그램을 종료하지는 않는다. 유효한 주소인데 필요한 페이지가 아직 준비되지 않았거나, 쓰기 시 복사 때문에 쓰기 권한을 잠시 막아 둔 경우에는 커널이 매핑을 고치고 명령을 다시 실행할 수 있다. 자세한 복구 과정은 페이지 폴트의 처리에서 다룬다.

반대로 허용되지 않은 사용자 접근을 복구할 수 없다면 운영체제는 프로그램에 오류를 통지한다. Linux에서는 잘못된 메모리 접근에 SIGSEGV 같은 시그널 (Signal)을 보낼 수 있으며, 기본 처리에 따르면 프로세스가 종료된다. CPU가 직접 시그널을 보내거나 프로그램을 죽이는 것은 아니다. 예외를 어느 시그널로 바꿀지는 아키텍처와 운영체제의 처리 규칙에 달려 있다.[10]

시그널은 보호 위반 전용 통지가 아니다. 종료 요청이나 자식 프로세스의 상태 변화도 알릴 수 있다. 발생·전달·처리와 다른 통신 방식의 관계는 프로세스 간 통신에서 설명한다. IDT를 통한 진입과 복귀는 장치 제어와 입출력의 IDT 절로 이어진다.

시스템 콜과 요청 검증

격리된 프로그램도 파일을 읽고 다른 프로그램과 통신해야 한다. 시스템 콜(System Call)은 프로그램이 정해진 진입점을 통해 커널에 작업을 요청하는 경로다. 하드웨어가 제어를 커널로 넘기면 커널 코드가 요청을 처리하고 결과를 돌려준다. 진입·복귀 과정은 장치 제어와 입출력을 참조한다. 호출자는 요청할 작업과 인자를 고를 수 있지만 커널에서 실행할 임의의 명령을 지정할 수는 없다.

예를 들어 데이터를 읽어 달라는 요청에는 결과를 저장할 사용자 주소와 최대 길이가 들어간다. 커널은 그 주소 범위가 사용자 공간에 속하고 요청한 방향으로 접근 가능한지 확인해야 한다. 주소와 길이를 합산할 때 정수 범위를 넘는 경우도 고려해야 한다. 호출자가 넘긴 숫자나 포인터를 신뢰하면 커널이 자신의 권한으로 금지된 접근을 대신 수행할 수 있다. [11]

검사 시점과 사용 시점 사이의 변화도 문제다. 다른 스레드가 인자가 담긴 메모리를 바꿀 수 있다면, 검사한 내용과 실제 처리한 내용이 달라질 수 있다. 필요한 값을 커널의 관리 영역에 복사하거나, 대상의 수명을 유지하고 동기화하는 등 처리 과정 내내 가정이 유지되도록 만들어야 한다.

격리와 협력의 비용

경계를 넘을 때는 실행 상태를 보존하고 복원하며 인자와 권한을 확인하는 비용이 든다. 데이터를 복사해야 할 수도 있다. 다만 커널에 진입했다고 반드시 다른 스레드로 컨텍스트 스위치(Context Switch)를 하는 것은 아니다. 요청을 바로 끝내면 같은 스레드가 돌아오고, 기다려야 한다면 다른 스레드에 CPU를 넘길 수 있다.

이미지 처리 기능을 별도 프로세스에 두면 그 기능의 잘못된 메모리 쓰기가 편집기의 메모리를 직접 훼손하는 일을 막을 수 있다. 대신 큰 이미지와 결과를 주고받는 통신 비용이 생긴다. 공유 메모리로 복사를 줄이면 같은 영역에 대한 동기화와 접근 범위를 관리해야 한다. 경계를 나누는 단위와 데이터를 교환하는 방식은 함께 설계해야 한다.

하드웨어가 접근 경계를 지켜도, 쓰기가 허용된 메모리에 잘못된 계산 결과를 저장하는 것까지 막지는 않는다. 공유 영역에서 두 스레드가 같은 값을 동시에 갱신하는 문제도 남는다. 하드웨어 보호는 실행과 접근의 경계를 강제하고, 그 경계 안의 계산과 동기화는 프로그램이 올바르게 구성해야 한다.

참고 문헌

  1. ↑ Linux 커널 문서, Subsystem drivers using GPIO, poweroff-gpio.
  2. ↑ RISC-V, Machine-Level ISA, Physical Memory Protection.
  3. ↑ Jerome H. Saltzer·Michael D. Schroeder, The Protection of Information in Computer Systems (1975), §I.
  4. ↑ MIT xv6: Operating system organization, 사용자 모드·커널 모드와 하드웨어 격리.
  5. ↑ Linux 커널 문서: Page Tables, 주소 변환과 페이지 접근 권한.
  6. ↑ Intel, Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 2A, HLT, Protected Mode Exceptions.
  7. ↑ RISC-V Privileged Architecture: Control and Status Registers, CSR 접근 권한과 불법 명령 예외.
  8. ↑ 8.0 8.1 8.2 Intel, Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A, 예외 처리, 특권 수준 및 I/O 보호.
  9. ↑ RISC-V Privileged Architecture: Supervisor-Level ISA, stvec·scause와 예외 처리.
  10. ↑ Linux man-pages: signal(7), signal dispositions 및 hardware exception에서 발생하는 시그널.
  11. ↑ xv6: Traps and system calls.