QEMU를 이용한 하드웨어 모델링

반응형

code_sample.zip
0.01MB

1. QEMU의 필요성

QEMU는 가상화와 에뮬레이터 기능을 제공하는 오픈소스 소프트웨어이다. 동적 변환기를 사용하여 성능이 뛰어난 것이 특징이며 소프트웨어 스택 전체를 가상머신 위에서 실행할 수 있다. x86 시스템을 에뮬레이트 하기 위해서 만들어졌지만 현재는 ARM, MIPS, ALPHA 등의 다양한 프로세서에 대한 에뮬레이팅 환경도 제공한다.

에뮬레이터를 이용하면 실제 물리적인 하드웨어 없이 시스템 개발을 할 수 있는 장점이 있다. QEMU는 프로세서뿐 아니라 디바이스 단위까지 에뮬레이션을 제공하기 때문에 실제 타깃보드에서 실행하는 것과 동일한 효과를 얻을 수 있다.

임베디드 시스템용 소프트웨어는 개발과정에 많은 자원을 필요로 한다. 특히 디버깅이 어려운 편이며 디버깅 장비 또한 고가에 속한다. 하지만 QEMU에서 제공하는 모니터 기능을 이용하면 쉬운 디버깅이 가능하다.

QEMU가 활용되는 대표적인 사례는 Android 에뮬레이터이다. Android 에뮬레이터는 QEMU기반으로 개발되었으며 “Goldfish”라는 이름을 가진 가상의 타깃 보드를 제공한다. 그 결과 복잡한 개발 환경과 고가의 장비로 인해 임베디드 시스템 개발에 어려움을 겪었던 개발자들이 별다른 어려움 없이 Android 개발을 할 수 있게 되었다.

2. 하드웨어 모델링

이 글에서는 QEMU에서 제공하는 API를 이용하여 PowerPC 아키텍처 기반 타겟보드의 메모리 레이아웃을 구성하고 인터럽트 처리루틴을 구현하여 UART 기능을 어떻게 구현하는지 보여준다. QEMU는 다양한 아키텍처, CPU, 레퍼런스 보드를 지원하지만 실제 사용하려는 타깃보드에 별도의 외부 모듈이 장착되어 있을 경우 이는 직접 하드웨어 모델링 과정을 통하여 구현해야 한다.

타깃 보드는 PowerPC 아키텍처 기반의 MPC8560이고 이 보드의 UART 기능을 구현하는 것을 목표로 한다. QEMU에서 제공하는 PowerPC 아키텍처 기반의 기본 타깃보드는 MPC8544이다. MPC8544와 MPC8560의 차이점은 communication processor module(CPM)의 유무이다.(아래 그림의 빨간 네모박스 부분의 모듈이 통째로 없고, 여기서 UART 기능에 필요한 부분을 직접 구현해야 한다.)

MPC8560 블록 다이어그램

MPC8560의 communication processor module(CPM)은 UART, Fast Ethernet 등의 high bit-rate 프로토콜의 성능 향상을 위한 통신모듈이다.
Communication processor(CP)는 클럭당 하나의 인스트럭션을 실행할 수 있으며 내부의 ROM 또는 I-Memory의 코드를 실행한다. 내부 타이머를 가지고 있으며 이 타이머로 인터럽트를 발생시켜 버퍼 상태를 감시하기도 한다. CP와 e500 코어 프로세서와의 데이터 교환에는 공유메모리 영역을 이용한다. PowerPC에서는 그 메모리 영역을 Dual-port RAM으로 부르고 있으며 MPC8560에서는 32-Kbyte 크기를 가진다. 이 영역은 유연한 고속 통신 매체가 되어 준다.

MPC8560 CPM 블록 다이어그램

 

Communication processor module(CPM)의 UART 기능을 활성화 하기 위해서는 MPC8560 CPM 블록 다이어그램에서 기능 동작에 필요한 모듈을 파악해야 한다. 실제 하드웨어는 CPM 내부의 ROM 또는 I-RAM에 적재되어 있는 인스트럭션을 communication processor(CP)가 e500코어와 독립적으로 실행하며 동작을 하지만 QEMU에는 CPM모듈 코드가 없다. 따라서 CPM 모듈을 생성하고 실제 하드웨어와 동일한 동작을 하도록 프로그래밍을 해야 한다.

커널이 동작하고 있는 e500 코어 프로세서에서 printf 함수가 호출되면 최종적으로 UART 1바이트 출력 함수가 호출되고 그 함수는 Communication processor module(CPM)의 Dual-port RAM의 지정된 영역에 출력할 데이터를 기록하게 된다. 그리고 CPM의 내부 타이머에 의해 동작하는 인터럽트 컨트롤러에 의해 데이터가 기록된 영역의 Status 레지스터가 매우 빠른 주기로 모니터링되고 있다가 Status 값이 데이터 입력으로 판정되면 외부로 출력하는 구조로 되어 있다.

Dual-Port RAM 메모리 맵

소프트웨어적으로 에뮬레이션하는 관점에서 하드웨어적인 polling 방식을 동일하게 구현하면 CPU 사이클 낭비가 심하다. 대신 QEMU에서 지원하는 memory-mapped I/O 기능을 이용하여 dual-port RAM의 해당 영역에 I/O 속성을 주어 인터럽트 방식으로 처리를 한다. 이렇게 하면 CPM의 내부 타이머는 고려하지 않아도 된다.

Dual-port RAM의 UART 영역은 데이터 시트의 Dual-port RAM 메모리 맵과 데이터 저장 포맷을 파악해야 한다. UART 기능은 CPM 내부의 serial communication controller(SCC)가 담당하며 UART 모드로 동작하도록 설정이 필요하다.

SCC 채널과 관련있는 데이터는 Dual-port RAM의 Buffer Descriptor(BD) 영역에 저장된다. 이 값들은 모든 시리얼 컨트롤러들이 서로 공유한다.

SCC BD and Buffer Memory Structure

QEMU로 UART 기능을 활성화하기 위해서는 하드웨어의 명세를 상세히 파악해야 한다. 특히 동작 시나리오를 정확히 파악해야 하는데 데이터 시트에는 시나리오가 구체적으로 기술되어 있지 않으므로 커널 소스코드를 통해 동작 원리를 파악하거나 바이너리 파일만 있을 경우에는 역어셈블 결과의 분석을 통해 파악해야 한다.

SCC Parameter RAM

위 그림에 따르면 SCC 1번 채널이 할당되어 있는 Dual-port RAM의 메모리 뱅크 베이스로부터 오프셋 +2 위치에 TBASE 주소값이 있는 것을 알 수 있다. TBASE에는 TX buffer descriptor table이 존재한다. 이 테이블의 정보는 아래와 같다.

SCC Buffer Descriptors(BD)

Status and Control 필드는 e500 코어 프로세서와 CPM이 상태정보를 교환하는 용도로 사용된다. 동시 접근은 허용하지 않으며 이를 위해 Status 값을 검사 후 Control 값을 기록한다. 예를 들어 e500 코어프로세서가 UART로 문자를 출력할 경우, Status 비트를 검사하여 UART 버퍼가 사용가능 상태인지 확인 후 데이터를 기록한다.

코어 프로세서와 Communication processor module 간의 상호 데이터 교환은 크게 네 가지로 분류할 수 있다.

  • 코어 프로세서의 명령어 전달
    SCC 채널의 상태를 변경하거나 SCC 채널을 초기화하도록 한다.
  • 버퍼 디스크립터 조작
    전송할 데이터를 전달하고 전송받은 데이터를 어느 메모리 주소에 저장해야 할지 알려준다. 그리고 송수신시 발생한 에러를 리 포트 한다.
  • 이벤트 레지스터
    코어 프로세서가 특정 레지스터에 데이터를 기록하면 이벤트가 발생한다. 반대로 코어 프로세서로 인터럽트를 생성하는 목적으로 사용된다.
  • 레지스터 속성값 설정
    SCC의 동작 모드를 설정하고 클럭을 설정한다. 그리고 물리 인터페이스의 종류를 결정한다.

    코어 프로세서로부터 각 명령어가 전달되면 CPM은 정의된 동작을 수행하는데 그 종류는 아래와 같다.
  • INIT TX PARAMETERS
    SCC parameter RAM의 TBASE 값을 TBPTR에 복사하고 TSTATE 값을 0으로 초기화한다.
  • INIT RX PARAMETERS
    SCC parameter RAM의 RBASE 값을 RBPTR에 복사하고 RSTATE 값을 0으로 초기화한다.
  • ENTER HUNT MODE
    채널에 명령어를 전송하여 모든 입력되고 있는 데이터를 무시하도록 하고 다시 IDLE 상태가 될 때까지 기다리도록 한다.
  • STOP TX
    대기 중인 데이터의 전송을 멈춘다.
  • GRACEFUL STOP TX
    채널의 버퍼를 끝까지 전송한 다음에 전송과정을 끝내도록 한다.
  • RESTART TX
    멈춘 상태의 전송을 다시 시작한다.

    Communication processor module(CPM)이 코어 프로세서로부터 데이터 전송을 명령받으면 다음의 시나리오로 동작한다. TxBD 테이블 포인터를 참조하여 첫 번째 버퍼 디스크립터에 접근한다. 이때 “INIT TX PARAMETERS”명령이 사전에 실행되지 않았다면 TBPTR은 알 수 없는 값을 가지므로 주의해야 한다. 버퍼 디스크립터의 Status 필드의 Ready bit 값 설정 여부를 감지한다. 이 Ready bit는 앞서 언급했듯이 CPM의 내부 클럭에 의해서 polling 되며 이더넷의 경우 128 클럭 주기, UART의 경우에는 64 클럭 주기로 polling 된다. 이 동작은 CPM의 내부 동작이기 때문에 코어 프로세서는 이를 알지 못한다. Ready bit가 설정되어 있으면 전송할 데이터가 있다는 신호이므로 buffer length 값을 임시 카운터 영역인 T_CNT에 저장하고 버퍼의 시작 주소를 임시 포인터 영역인 T_PTR에 저장한다. 하드웨어에서 이 동작이 이루어지지 않는다면 TX 클럭 설정 부분부터 다시 살펴봐야 하며 QEMU로 이 부분을 구현할 때는 실제 클럭을 사용하지 않고 메모리 접근 이벤트 방식으로 구현한다. 전송을 시작하면 T_CNT는 감소시키고 T_PTR은 증가시킨다. 이때 TSTATE의 값을 0으로 두면 전송 중간에 코어 프로세서가 개입할 수 있으므로 주의해야 한다. 전송데이터 길이에 따라 여러 버퍼가 사용될 수 있다. 따라서 버퍼를 비웠을 때 다음 버퍼에 데이터가 존재하는지 항상 체크해야 한다.

    커널에서 저수준 입출력 함수를 호출했을 경우에는 CPM 레지스터에 직접적으로 데이터를 기록하고 이벤트를 발생시키지만 printf와 같은 고수준 입출력 함수가 호출되었을 경우에는 메모리 입출력 동작뿐 아니라 코어 프로세서와 CPM 사이에 인터럽트를 주고받게 된다. printf에 바이트를 초과하는 문자열이 입력되었을 경우 내부적으로 루프를 돌면서 바이트 단위로 TX 버퍼에 저장되도록 하는데 이때 루프가 호출되려면 CPM이 발생시킨 인터럽트를 코어 프로세서가 처리하고 그 ACK가 CPM에 전달되어야 한다. 이를 해결하는 방법은 아래의 인터럽트 항목에 있다.
    메모리 Communication processor module(CPM)의 dual-port RAM을 기존의 메모리 맵에 추가하려면 상위 메모리 맵의 구조를 파악해서 그중 Dual-port RAM 영역으로 할당받은 주소를 확인해야 한다.

Top-Level Register Map

위 그림을 보면 CPM 메모리 영역은 Configuration, Control, and Status Register Map(CCSR) 영역의 하위 트리에 위치하고 있는 것을 확인할 수 있으므로 CCSR 영역 하위에 CPM 메모리 영역을 할당해야 한다. 이에 앞서 QEMU에서 CPM을 하나의 주변장치로서 인식할 수 있도록 장치등록을 해야 한다.

CPM 장치 초기화

현재 구현하고자 하는 CPM은 PowerPC 아키텍처의 e500 프로세서 기반 보드의 주변장치 이므로 e500 프로세서가 구현되어 있는 "hw/ppc/e500.c”파일의 “ppce500_init”함수에 위 초기화 코드를 삽입한다. 이때 호출되는 각각의 함수가 수행하는 역할은 아래와 같다.

  • DeviceState qdev_create(BusState *bus, const char *name)
    새로운 장치를 생성하는 함수이다. 오브젝트만 생성할 뿐이지 실제동작은 qdev_init 계열의 함수가 호출되고 나서 이루어진다.
  • void object_property_add_child(Object *obj, const char *name, Object *child, Error **errp)
    qdev_create 함수로 CPM 오브젝트를 생성했으므로 오브젝트 트리를 구성해야 한다. 앞에서 CPM은 CCSR의 하위 모듈인 것을 알았으므로 CCSR의 차일드 오브젝트로 등록한다. 이 때 CCSR의 오브젝트를 얻어와야 하는데 OBJECT 매크로를 활용하면 된다.
  • qdev_init_nofail(DeviceState *dev)
    앞서 create함수로 CPM 오브젝트를 생성했고 그 오브젝트를 CCSR의 하위 모듈로 등록을 마쳤다. 이제 실제 동작을 할 수 있도록 init함수를 호출한다. 이 함수는 qdev_init 함수와 유사하지만 에러가 발생했을 경우 그 에러값을 리턴하는 대신 프로그램을 종료한다.
  • void mpc8560_cpm_init_serial(CPMState *s, CharDriverState *chr)
    시리얼 장치의 핸들러 등록, fifo 생성 밑 크기 설정, 폴링 주기 설정, 타임아웃 설정, 보드레이트 설정을 하는 함수이다. 함수 내부는 UART 기능 설정을 설명하는 부분에서 상세히 다룬다.

sysbus_connect_irq(SysBusDevice *dev, int n, qemu_irq irq)
CPM이 발생시키는 IRQ를 코어 프로세서로 전달하기 위해서는 이 함수를 이용하여 설정해야 한다. 이를 위해서는 CPM이 할당받은 내부 인터럽트 번호를 확인해야 한다. 아래 그림을 보면 내부 인터럽트 번호로서 30번이 할당되어 있는 것을 확인할 수 있다. 하지만 앞의 QEMU 코드는 “mpic [46]”을 인자값으로 넘기는 것을 볼 수 있는데 이는 QEMU가 외부 인터럽트와 내부 인터럽트를 하나의 배열에서 관리하기 때문에 0 ~ 15까지의 “mpic” 배열 인자는 외부 인터럽트에 할당되어 있기 때문이다.

내부 인터럽트 할당

void memory_region_add_subregion()
기존의 메모리 영역에 하위 영역을 트리 형식으로 추가하는 함수이다. CPM의 메모리 영역이 CCSR 메모리 영역의 시작점으로부터 오프셋 0x80000에 위치하므로 오프셋 인자로 0x80000을 전달했다. 그 결과는 아래 그림을 통해 확인할 수 있다. QEMU에서 “ctrl+a+c”입력을 통해 QEMU 모니터 모드로 진입할 수 있으며 이 상태에서는 내부 시스템의 모든 정보를 확인할 수 있다. “info mtree”명령어를 통해 메모리 트리정보를 확인할 수 있다. 전체 시스템의 메모리가 최상위에 위치하고 있고 “mpc8560-cpm”의 메모리 영역은 “e500-ccsr”의 하위에 위치하고 있는 것을 확인할 수 있다. “mpc8560-cpm”의 하위 메모리 영역은 CPM 디바이스에서 추가적으로 등록했다.

memory tree info

위 과정을 통해 e500 코어의 주변장치로 CPM을 생성하고 활성화하였으며 메모리 영역의 확보도 마친 상태이다. 이제 실제 CPM의 내부 프로그래밍을 통해 상세 동작을 구현해야 한다. 그러기 위해서는 QEMU의 디바이스 프로그래밍 규약을 알아야 한다. 이 규약은 리눅스의 디바이스 드라이버 방식과 매우 유사하다. 먼저 생성할 주변장치의 정보를 담을 구조체를 생성하고 장치 초기화 단계에서 초기화 함수가 호출되도록 하면 된다.

CPM 장치 파일의 초기화 부분

새로 추가한 장치의 초기화 함수가 호출되기 위해서는 type_init() 함수를 이용한다. 그전에 TypeInfo 타입의 구조체에 장치 이름과 초기화 때 호출될 함수 포인터를 저장해 두면 된다. 실질적으로 장치 초기화를 수행하는 함수는 접미사에 _realize가 붙은 함수이고 CPM에서는 CPM의 하위 메모리 영역을 초기화했다.
메모리영역 초기화는 단순 입출력 속성과 Memory-mapped I/O 속성을 선택할 수 있다.
CPM에는 Dual-port RAM 영역이 두 개 존재하므로 각각 “cpm.dpram1”, “cpm.dpram2”로 이름을 정하고 CPM의 하위 메모리 영역으로 구성하였다. 그중 UART 기능 수행 시 Status 값의 변경이 이루어지는 주소 영역은 Memory-mapped I/O 속성을 주었다. 이 속성을 줄 수 있는 API가 memory_region_init_io() 함수이다.

CPM 하위 메모리 영역 초기화
CPM의 레지스터 맵

(CPM의 레지스터 맵, 데이터 시트를 보고 한자, 한자 막일로 채워 넣음)

Memory-mapped I/O 속성이 메모리 번지수 0x100부터 0x110까지 지정되어 있다고 할 때, 0x104에 메모리 접근이 감지되면 사전에 설정된 핸들러가 호출된다. 이때 핸들러로 넘겨지는 메모리 주소값은 0x104가 아닌 0x4이다. 하지만 레지스터맵의 각 레지스터 주소는 절대주소를 가지고 있기 때문에 별도의 계산을 해줄 필요가 있다. 가독성을 높이기 위해서는 절대 주소값을 가지는 레지스터 이름을 그대로 사용하는 것이 바람직하므로 아래의 매크로를 따로 정의하였다.

절대주소-상대주소 매크로

위 그림에서 CPM_REG_SCCE1 레지스터에 접근이 이루어졌을 경우 SCC1 베이스 어드레스 0x91a00에 등록된 핸들러가 호출되고 CPM_REG_SCCE1의 오프셋이 인자로 전달된다.

에뮬레이션 과정은 많은 부분이 레지스터 조작에 할애된다. 레지스터는 비트 연산자를 이용하는데 이 또한 매크로를 사용하지 않으면 코드 가독성이 떨어진다. 보통 레지스터 조작 시 X번 비트부터 Y비트 크기만큼 읽어서 값을 조작 후 다시 같은 자리에 쓰는 작업을 한다. 그래서 부분 조작이라는 의미의 접두사를 붙인 PARTIAL_XXX 매크로를 정의하여 사용하였다.(아래 그림 참조)

레지스터 비트 마스킹용 매크로

3. 인터럽트

주변 장치에서 코어프로세서로 인터럽트가 정상적으로 전달되면 코어프로세서로 ACK가 전달된다. 이 ACK를 받으면 Pending 레지스터의 해당 인터럽트 비트를 초기화해 주면 된다. QEMU에서는 인터럽트 발생을 위해 “qemu_irq” 함수를 제공한다.

실제 하드웨어에어 동작하는 커널의 경우 많은 부분의 하드웨어 초기화가 부트로더에서 수행된다. 리눅스가 아닌 커스텀 운영체제의 경우 부트로더에서 초기화한 하드웨어는 따로 초기화를 하지 않는 경우가 많다. 그로 인해 실제 하드웨어에서 정상적으로 동작하는 코드가 QEMU에서는 그렇지 않을 수 있다.

결과적으로 별도의 디버깅을 수행해야 하므로 커널 개발 시 에뮬레이터를 지원하려면 하드웨어 초기화 코드를 커널에 포함시키는 것이 유리하다. 그렇지 않으면 QEMU에서 하드웨어를 조작해야 하는데 하드웨어 스스로 자신을 초기화시키는 모양새가 되고 특정 커널 이미지에 종속적인 패치를 하게 되는 것이므로 유의해야 한다.

바람직 하지 않은 하드웨어 인터럽트 초기화 코드 예시

4. UART 기능

QEMU는 캐릭터 디바이스 에뮬레이션을 제공한다. 이 캐릭터 디바이스를 이용하여 UART 기능을 사용할 수 있다. 사용자가 설정해야 할 속성으로는 송신, 수신 처리 핸들러와 fifo 사이즈 설정, poll모드, 그리고 전송 속도 관련 설정이다. 그 예는 아래 그림에 나와있다.

캐릭터 디바이스 초기화 코드

반응형

'프로그래밍 > C | C++' 카테고리의 다른 글

[MFC] C++ MiniDumpWriteDump  (0) 2022.08.12
[MFC] 로그(Log) 출력 Logger  (0) 2022.08.12
[MFC] simdjson library 설치  (0) 2022.08.01
[MFC] TA-LIB 설치  (0) 2022.08.01
엑셀을 활용한 C코드 생성  (0) 2020.11.12