2026년 3월 1일 일요일

[베릴로그 RTL 예제] 탁구 게임기 -2편: 그래픽 LCD 모델링-

[베릴로그 RTL 예제] 탁구 게임기 -2편: 그래픽 LCD 모델링-

목차:

1. RTL 베릴로그로 탁구대 그리기

2. 그래픽 LCD 구동 칩의 시뮬레이션 모델
    2-1. 도트 매트릭스 그래픽 LCD 구동 칩
    2-2. 인터페이스 프로토콜
    2-3. 그래픽 데이터 메모리
    2-4. SystemC 모델
        a. 리셋 동작
        b. 명령 또는 데이터 구분
        c. 명령 해석
        d. 그림 데이터 접근(읽기 또는 쓰기)
    2-5. 실습
        a. 따라하기
        b. 과제

--------------------------------------------------------

2. 그래픽 LCD 구동 칩의 시뮬레이션 모델

비디오 게임기를 설계하려던 참이다. 화면은 가로 128, 세로 64개의 점을 찍을 수 있는 크기로 정했다. 한 화면은 총 8192개의 점으로 구성된다. 공과 패들이 움직이는 모습을 보여 주려면 초당 10개 화면이 만들어져야 한다. 간단해 보이지만 무려 초당 8만 1천여개(=128x64x10)의 점을 쏟아내는 하드웨어를 설계하는 것이 목표다. 게임기의 비디오 화면을 확인하기 위해 한 화면에 해당하는 8천여개의 클럭 신호를 일일이 확인하는 것은 불합리 하다. 앞서 화면 좌표 생성과 점을 찍는 기본 디지털 회로(카운터)를 구상해 봤었다. 그래픽 LCD 구동 칩의 시뮬레이션 모델을 만들어 베릴로그로 기술한 하드웨어로 그래픽 신호를 생성하고 이를 시현하려고 한다. 게임기 하드웨어는 아직 개발중이므로 디지털 회로의 시뮬레이션 결과를 그래픽 화면으로 보면서 확인할 수 있도록 그래픽 LCD 장치의 모델을 작성해보자.

SDL(Simple Direct Layer)은 컴퓨터의 멀티미디어 입출력 장치를 쉽게 다룰 수 있도록 공개된 오픈-소스 라이브러리다. C++ 로 작성되어 게임, 에뮬레이터, 오디오 재생 등 다양한 응용 프로그램에 널리 활용되고 있다.

2-1. 도트 매트릭스 그래픽 LCD 구동 칩

도트 매트릭스 그래픽 LCD 는 가장 단순한 그래픽 표시 장치다. 내부의 메모리에 저장해 놓은 디지털 그림 데이터를 보고 가로와 세로로 화소를 배치한 LCD 패널에 점을 찍는다. KS0108은 도트 매트릭스 LCD 구동 칩으로 잘 알려져 있다. 1990년대에 삼성전자에서 출시한 이 칩은 단순 그래픽 LCD 구동 칩의 "사실상 표준"으로 지금도 여러 곳에서 생산 되고 있다[참고]. 이 칩을 채택한 LCD 장치를 구동하는 각종(아듀이노를 포함하여) 소프트웨어 라이브러리 코드들을 쉽게 구할 수 있지만 하드웨어 시뮬레이션 모델은 찾을 수 없어서 제작하기로 한다. 시뮬레이션 모델은 하드웨어로 제작하려는 목적이 아니므로 굳이 베릴로그로 작성할 필요없다. 더구나 그래픽을 시현할 LCD 패널의 모델링을 HDL로 작성하기는 불가능에 가깝다. 하드웨어의 행위는 C++의 크래스 라이브러리 SystemC로, LCD 그래픽 시현은 SDL을 활용하여 KS0108 칩[데이터쉬트]을 모델링 한다.

위 그림은 64x64 화소를 구동할 수 있는 KS0108 칩의 내부 구성을 보여준다. 128x64 화소를 가진 LCD 패널을 구동하기 위해  두개의 칩을 사용하고 있다. GLCD 모듈 [데이터 쉬트]의 내부 구성은 아래와 같다. 

2-2. 인터페이스 프로토콜

그래픽 LCD는 MPU(또는 CPU)등 디지털 계산 장치의 출력을 시현하는 주변장치다. MPU는 정해진 입출력 규격과 절차(protocol)에 따라 주변 장치에 접근하여 제어한다. GLCD 모듈의 사양서[데이터 쉬트]에 입출력 신호선의 용도를 다음과 같이 기술하고 있다. 전원(VDD, VSS, VEE)을 제외한 대부분 제어 핀들은 MPU로부터 입력되지만 DB0-DB7까지 8개의 데이터 핀은 양방향이다. MPU는 LCD 모듈 구동 칩 내에 그림 데이터를 저장하거나 읽어갈 수 있다. 

LCD에 그림이 나타나게 하려면 데이터 메모리(Display Data RAM)에 그림 데이터를 넣어 주어야 한다. 위의 핀 구성표를 보면 가로세로 위치를 지정하고 화소 값을 써넣기 위한 데이터 메모리의 주소 핀이 보이지 않는다. 입출력 핀의 수를 늘이면 전력 소모면에서 불리할 뿐만 아니라 주변 장치를 장착하기 위한 배선의 수가 증가한다. 이는 상품 제조 비용이 높아지는 등 시장성이 떨어진다. 더구나 빠른 데이터 전송이 필요하지 않은 사용자 인터페이스용 주변장치를 감안 하면 주소와 데이터 버스가 별도로 존재하는 고속의 인터페이스를 가질 필요 없다. 게다가 메모리에 접근은 어짜피 두 단계 순서를 거친다. 먼저 주소를 지정하고 읽기 또는 쓰기 제어 신호를 주어야 비로서 데이터 값을 참조할 수 있다.

그래픽 LCD 모듈에 내장된 데이터 메모리에 접근 하는 방법으로 데이터 버스 DB0-DB7의 용도를 주소와 데이터 용으로 겸한다. 데이터 버스에 실린 8비트 값이 주소를 지정하는지 또는 그림 데이터를 의미하는지 구분하기 위해 D/I 핀을 두고 있다. 위의 기능 설명에 따르면 D/I 핀이 0(L)일때 데이터 버스에 실린 값은 명령(또는 주소지정)이며 1(H)일때 그림 데이터다. CS는 두개의 구동 칩 중 하나를 선택한다. 그래픽 LCD 모듈의 제어 명령표는 아래와 같다. 그래픽 LCD 모듈의 RS 핀은 구동 칩의 D/I 에 해당한다.

그래픽 LCD를 구동하는 칩(KS0108)의 내부 구성에서 MPU와 인터페이스 부분은 디지털 회로다. 데이터 버스에 실린 값을 구동 칩의 내부 레지스터(또는 그래픽 메모리)에 저장하려면 클럭의 동기를 맞춰야 한다. 디지털 회로의 D-플립플롭에서 클럭에 해당하는 신호가 E 다. 그래픽 LCD 모듈의 입출력 타이밍은 다음과 같다.

MPU에서 GLCD로 데이터(또는 명령)을 써넣는 타이밍이다. R/W=L을 준 후 E 가 상승 엣지 일때 데이터 버스의 값을 구동 칩 내부의 레지스터에 저장한다.

MPU가 GLCD의 데이터를 읽는 타이밍이다. R/W=H로 준 후 E 가 상승 엣지 이면 데이터 버스에 구동 칩의 내부 값이 실리고 MPU는 이를 읽는다.

2-3. 그래픽 데이터 메모리

GLCD의 화면은 가로 128, 세로 64로 화소점의 수는 총 8192개다. 흑백 LCD 모듈 이므로 화소점 당 1비트가 할당된다. 임의의 위치에 점을 찍거나 지우기 위해 각 화소점마다 주소를 지정하려면 무려 13(가로 주소 비트 폭 7, 세로 주소 비트폭 6)의 선이 필요하다. 소요 선폭이 넓으면 회로가 복잡해지고 배선 소요가 많아 매우 불리하다. 선폭이 넓이는 디지털 회로의 큰 단점으로 꼽힌다. GLCD 모듈의 핀 설명서에 세로축 주소(Y Address)에 6비트를 할당 하지만 0에서 127까지 표현해야하는 가로축(X Address)으로는 단 3비트에 불과하다.

위의 설명에 따르면 가로 주소(X Address)를 지정하는 용도라면서 "페이지"라는 표현을 쓰고 있다. 화소점의 주소가 아니라는 것을 짐작할 수 있다. "페이지"는 8비트로 구성된 바이트(byte)를 의미한다. 페이지 당 8 비트 이므로 8 페이지는 64개의 화소점을 지정할 수 있다.  두개의 구동 칩을 사용하여 각각 64개씩 도합 128개 점을 표현할 수 있다. CS1=0, CS2=1 일때 0부터 63까지 가로축 화소를 지정하며 CS1=1, CS2=0 일때 64부터 127까지 가로축 화소 점을 지정한다.

흑백 GLCD의 그림 데이터 메모리 구조에서 임의의 화소점에 점을 찍으려면 가로축 주소(Y Address)와 페이지 주소(X Address) 준 후 써넣는 데이타(Data)의 비트 위치에 1 또는 0을 넣는다. 예를 들어 좌표 (10, 67)에 위치한 화소에 점을 찍으려면 CS1=1, CS2=0, Y Address=0x03, X Address=0x01에 Data=0x04를 써넣는다.

2-4. SystemC 모델링

그래픽 LCD의 시뮬레이션 모델을 SystemC로 작성해보자. 크래스 헤더 파일 sc_glcd128x64.h 에 모듈의 외형을 기술 한다. SystemC 모듈 크래스 명은 sc_gcld128x64 다. GLCD 모듈에 맞춰 입출력 포트를  기술하면 다음과 같다. 양방향 데이터 버스는 입력과 출력으로 분리하였다.

//
// Filanema: sc_glcd128x64.h
//

#ifndef _SC_GLCD128x64_H_
#define _SC_GLCD128x64_H_

#include <systemc.h>

SC_MODULE(sc_glcd128x64)
{
    sc_in<bool>     RS; // Register Mode Select: Inst(L),Data(H)
    sc_in<bool>     RW; // Read(H), Write(L)
    sc_in<bool>     E;  // Enable @ Posedge
    sc_in<sc_uint<8> >  DBi; // Data Bus
    sc_out<sc_uint<8> > DBo; // Data Output Bus
    sc_in<bool>     CS1;    // Chip-Select #1
    sc_in<bool>     CS2;    // Chip-Select #2
    sc_in<bool>     RST;    // Reset(L)

    void Renderer_Method(void);

    SC_CTOR(sc_glcd128x64)
    {
        SC_METHOD(Renderer_Method);
        sensitive << E << RST;

        ......

    }

    ~sc_glcd128x64()
    {}
};

#endif

구성자 SC_CTOR()은 크래스와 동일한 이름을 가져야 한다. 구성자 내에 SC_METHOD 형식의 콜백 함수를 입력 신호 E 와 RST의 사건에 감응 되도록 지정하였다. 콜백 함수 Renderer_Method() 는 모듈 크래스의 소속 함수로 입력 인수와 되돌림은 허용되지 않는다. 콜백 함수를 크래스를 정의한 헤더 파일과 별도로 sc_glcd128x64.cpp 에 작성 했다.

a. 리셋 동작

GLCD 모듈의 사양서에 따라 입출력 핀의 기능에 맞춰 내부 행동의 기술 한다. 콜백 함수 Renderer_Method() 는 E 와 RST의 사건에 의하여 호출된다. 먼저 RST에 대한 행동은 아래와 같다. RST=0(L)일때 리셋이다. 모듈의 내부 기능을 수행하는 모든 변수들을 초기화 한다.

//
// Filename: sc_glcd128x64.cpp
//

#include "sc_glcd128x64.h"
#include "glcd128x64_defs.h"

void sc_glcd128x64::Renderer_Method(void)
{
    if (!RST.read())    // RESET
    {
        opWrite = false;
        opInst = false;
        opCS1 = opCS2 = false;
        bDisplay = false;
        x0_address = y0_address = z0_address = 0;
        x1_address = y1_address = z1_address = 0;
    }
    else if (RST.read())    // NON-RESET
    {
        ......
    }
}

리셋이 해제되면 E의 상승 엣지 일때 CS1, CS2, RW, RS 등 입력 핀 값을 받아 상태를 지역변수에 저장해 둔다.

    if (!RST.read())    // RESET
        ......
    else if (RST.read())    // NON-RESET
    {
        if (E.read())   // Pos-Edge of E
        {
            opWrite  = RW.read()?  false:true;
            opInst   = RS.read()?  false:true;
            opCS1    = CS1.read()? false:true;
            opCS2    = CS2.read()? false:true;
            bDisplay = RST.read()? true:false;
        }
        else if (!E.read()) // Neg-Edge of E
        {
            ......
        }
    }

b. 명령 또는 데이터 구분

E의 하강 엣지 일때 데이터 버스에 실린 값을 받아 이를 명령 또는 데이터로 구분하고 내부동작을 수행한다.  앞서 E의 상승 엣지 일때 RW=0(L), RS=0(L) 이었다면 MPU에서 명령을 내린 것이다. 데이터 버스에 실린 값은 명령이다.

    if (!RST.read())    // RESET

        ......
    else if (RST.read())    // NON-RESET
    {
        if (E.read())   // Pos-Edge of E
            ......
        else if (!E.read()) // Neg-Edge of E
        {
            if (opWrite)  // Write Operation
            {
                DataBus = DBi.read();
                if (opInst)  // Instruction Write
                {
                    ......
                }
                else if (!opInst) // Data Write
                {
                    ......
                }
            }
            ......
        }
    }

c. 명령 해석

데이터 입력 버스 DBi 의 값을 명령으로 취하여 이를 해석하고 해당 동작을 수행한다. 데이터 입력 버스의 값에서 상위 두 비트의 의해 명령이 구분된다. 

해석한 X Address와 Y Address 설정 명령에 따라 8비트 데이터 버스에서 각각 하위 3비트 또는 6비트를 취하여 그림 데이터 메모리 주소로 저장해 두었다.

    if (opWrite)  // Write Operation
    {
        DataBus = DBi.read();
        if (opInst)  // Instruction Write
        {
            switch(DataBus & 0xC0)
            {
                case (INST_DISPLAY & 0xC0):
                    if (!DataBus[0])    // DISPLAY OFF
                        bDisplay = false;
                    else                // DISPLAY ON
                        bDisplay = true;
                        Renderer();
                        break;
                case (INST_SET_Y_ADDRESS & 0xC0):  // Set Y_ADDRESS
                    if (opCS1)
                        y0_address = DataBus.range(5,0);
                    if (opCS2)
                        y1_address = DataBus.range(5,0);
                    break;
                case (INST_SET_X_ADDRESS & 0xC0):  // Set X_ADDRESS
                    if (opCS1)
                        x0_address = DataBus.range(2,0);
                    if (opCS2)
                        x1_address = DataBus.range(2,0);
                    break;
                case (INST_SET_Z_ADDRESS & 0xC0):  // Set Z_ADDRESS
                    if (opCS1)
                        z0_address = DataBus.range(5,0);
                    if (opCS2)
                        z1_address = DataBus.range(5,0);
                    break;
                default:    break;
            }
        }
        else if (!opInst) // Data Write
            ......
    }

d. 그림 데이터 접근(읽기 또는 쓰기)

E의 상승 엣지에서 읽어둔 RS 값이 1(H)이면 데이터 입력 버스 DBi 의 값은 LCD 에 시현될 그림 데이터다.

앞서 명령 해석 단계에서 X와 Y 주소를 지역변수로 저장해 두었다. 해당 주소의 메모리에 데이터 입력 버스 DBi 값을 저장(쓰기)하거나 메모리에서 읽어 데이터 출력 버스 DBo로 내보낸다.

    if (opWrite)  // Write Operation
    {
        DataBus DBi.read();
        if (opInst)  // Instruction Write
            ......
        else if (!opInst) // Data Write
        {
            if (opCS1)
            {
                gMemory[0][x0_address][y0_address] = DataBus;
                y0_address++;
            }
            if (opCS2)
            {
                gMemory[1][x1_address][y1_address] = DataBus;
                y1_address++;
            }
            if (opCS1 || opCS2) // Render Screen when Display Memory written!
                Renderer();
        }
    }
    else if (!opWrite)  // Read Operation
    {
        if (opCS1)
        {
            DBo.write(gMemory[0][x0_address][y0_address]);
            y0_address++;
        }
        if (opCS2)
        {
            DBo.write(gMemory[1][x1_address][y1_address]);
            y1_address++;
        }
    }

그림 데이터 메모리에 새 값이 쓰이게 되면 즉시 LCD 화면을 갱신하기 위해 Render() 함수를 호출한다. 이 함수는 그림 데이터 메모리 gMemory[2][][]에서 값을 읽어 SDL의 그래픽 라이브러리를 통해 화면에 그림을 그려준다.

void sc_glcd128x64::Renderer(void)
{
    if (!bDisplay)  // DISPLAY OFF
    {
        SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE);
        SDL_RenderClear(renderer);
        SDL_RenderPresent(renderer);
    }
    else if (bDisplay)  // DISPLAY ON
    {
        for(int x=0; x<64; x++)
        {
            for(int y=0; y<128; y++)
            {
                int cs    = y/64;
                int page  = x/8;
                int y_pos = y%64;
                int x_pos = x%8;

                if (gMemory[cs][page][y_pos] & (0x01<<x_pos))
                    SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE);
                else
                    SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE);

                #ifdef ROTATE_SCREEN
                SDL_RenderDrawPoint(renderer, y, x);
                #else
                SDL_RenderDrawPoint(renderer, x, y);
                #endif
            }
        }
        SDL_RenderPresent(renderer);
    }
}

2-5. 실습

그래픽 LCD 구동 칩의 시뮬레이션 모델을 테스트 해본다. SystemC 모델과 시뮬레이션 테스트벤치의 구성은 다음과 같다.

    $ cd ~/ETRI050_DesignKit/Projects/RTL/pong_SbS/02_glcd128x64

    $ tree
    .
    ├── _Docs_
    │   ├── glcd-128x64-datasheet.pdf
    │   └── KS0108B.pdf
    └── simulation
        ├── Makefile
        ├── glcd128x64_defs.h
        ├── sc_glcd128x64.h
        ├── sc_glcd128x64.cpp
        ├── sc_glcd128x64_TB.h
        ├── sc_glcd128x64_TB.cpp
        └── sc_main.cpp

a. 따라하기

실습을 위한 테스트 벤치와 Makefile이 준비되어있는 예제 시뮬레이션 디렉토리로 이동한다.

    $ pwd

   ~/ETRI050_DesignKit/Projects/RTL/pong_SbS/02_glcd128x64/simulation

    $ make

    Makefile for sc_glcd128x64, SystemC model of "GLCD 128x64"

    TOP_MODULE=sc_glcd128x64 VCD_TRACE=YES|[NO] DEBUG_MSG=YES|[NO] ROTATE_SCREEN=YES|[NO] make build
    make run
    make wave
    make clean

모델을 시험해 볼 수 있는 테스트벤치와 함께 시뮬레이터를 빌드한다.

    $ ROTATE_SCREEN=YES VCD_TRACE=YES make build

    clang++  -I/opt/systemc/include \
                -L/opt/systemc/lib \
                -o sc_glcd128x64_TB \
                 -DVCD_TRACE -DROTATE_SCREEN \
                ./sc_main.cpp \
                ./sc_glcd128x64.cpp \
                ./sc_glcd128x64_TB.cpp \
                -lSDL2 -lsystemc

시뮬레이션 모델 테스트벤치를 실행 한다.

    $ make run

시뮬레이션을 그림으로 확인 할 수 있다. 디지털 파형을 보면 아래와 같다.

    $ make wave

b. 과제

예제 디렉토리에 시뮬레이션 모델과 테스트벤치 그리고 Makefile 까지 모두 준비되어 있다. 단지 따라해보기를 넘어 소스 코드를 읽고 토론을 통해 모델링과 테스트에 활용된 기법들을 이해하기 바란다. 시뮬레이션 결과로 보여주는 SDL 그래픽 시현과 디지털 파형을 테스트벤치와 비교하며 설명해보라. GLCD 시뮬레이션 모델을 시험하는 테스트벤치에서 #define 매크로를 적극적으로 사용하고 있다. 특히 SET_PIXEL(_X_,_Y_,_01_) 매크로를 눈여겨 살펴보라. 임의의 화소점 좌표에 점을 찍기 위해 그래픽 데이터 메모리 쓰기 SET_DATA(_CS1_,_CS2_,_DATA_) 전에 먼저 읽기 GET_DATA(_CS1_,_CS2_,_DATA_)를 수행하는 이유를 설명할 수 있어야 그래픽 LCD 모듈의 동작을 제대로 이해했다고 할 수 있다.

//
// Filename: sc_glcd128x64_TB.cpp
//

#include <unistd.h>
#include "sc_glcd128x64_TB.h"

#define SET_INST(_CS1_,_CS2_,_INST_)    \
{                                       \
    ......
}

#define SET_DATA(_CS1_,_CS2_,_DATA_)    \
{                                       \
    ......
}

#define GET_DATA(_CS1_,_CS2_,_DATA_)    \
{                                       \
    ......
}

#define SET_PIXEL(_X_,_Y_,_01_)         \
{                                       \
    SET_INST(true, true, INST_SET_Z_ADDRESS|0x00) \
    SET_INST((_Y_<64? true:false), (_Y_>63? true:false), INST_SET_Y_ADDRESS|(_Y_%64)) \
    SET_INST((_Y_<64? true:false), (_Y_>63? true:false), INST_SET_X_ADDRESS|(_X_/8)) \
    sc_uint<8>  _GD_DATA_; \
    GET_DATA((_Y_<64? true:false), (_Y_>63? true:false), _GD_DATA_) \
    if (_01_) _GD_DATA_ |=  (0x01<<(x%8)); \
    else      _GD_DATA_ &= ~(0x01<<(x%8)); \
    SET_INST((_Y_<64? true:false), (_Y_>63? true:false), INST_SET_Y_ADDRESS|(_Y_%64)) \
    SET_DATA((_Y_<64? true:false), (_Y_>63? true:false), _GD_DATA_) \
}

void sc_glcd128x64_TB::Test_Gen(void)
{
    RS.write(false);    // Register Mode
    RW.write(true);     // Read(H), Write(L)
    E.write(false);     // Enable @ Posedge
    DBi.write(0x00);    // Data Bus
    CS1.write(true);    // Chip-Select #1
    CS2.write(true);    // Chip-Select #2
    RST.write(true);    // Reset
    wait(100, SC_NS);
    RST.write(false);   // Reset(L)
    wait(100, SC_NS);
    usleep(2000);
    RST.write(true);
    wait(100, SC_NS);
    usleep(1000);

    SET_INST(true,true,INST_DISPLAY|0x01) // DISPLAY ON

    while(true)
    {
        for(int x=0; x<64; x++)
            for(int y=0; y<128; y++)
                SET_PIXEL( x, y, true)
        for(int y=0; y<128; y++)
        {
            int x = (int)(31*sin(y*2*M_PI/128)+32);
            SET_PIXEL(x, y, false)
        }
        for(int y=0; y<128; y++)
        {
            int x = (int)(31*sin(y*2*M_PI/64)+32);
            SET_PIXEL(x, y, false)
        }
        for(int y=0; y<128; y++)
        {
            int x = (int)(31*sin(y*2*M_PI/32)+32);
            SET_PIXEL(x, y, false)
        }
        sleep(1);
        SET_INST(true, true, INST_DISPLAY)  // Display OFF
        sleep(1);
        SET_INST(true, true, INST_DISPLAY|0x01) // Display ON
        sleep(1);
        for(int y=0; y<128; y++)
            for(int x=0; x<64; x++)
                SET_PIXEL( x, y, false)
        for(int y=0; y<128; y++)
        {
            int x = (int)(31*sin(y*2*M_PI/128)+32);
            SET_PIXEL(x, y, true)
        }
        for(int y=0; y<128; y++)
        {
            int x = (int)(31*sin(y*2*M_PI/64)+32);
            SET_PIXEL(x, y, true)
        }
        for(int y=0; y<128; y++)
        {
            int x = (int)(31*sin(y*2*M_PI/32)+32);
            SET_PIXEL(x, y, true)
        }
        sleep(1);
        SET_INST(true, true, INST_DISPLAY)  // Display OFF
        sleep(1);
        SET_INST(true, true, INST_DISPLAY|0x01) // Display ON
        sleep(1);
    }
}


2026년 2월 27일 금요일

[베릴로그 RTL 예제] 탁구 게임기 -1편: RTL 베릴로그로 탁구대 그리기-

[베릴로그 RTL 예제] 탁구 게임기 -1편: RTL 베릴로그로 탁구대 그리기-

목차:

개요

1. 탁구대 그리기
    1-1. 래스터 스캔 방식 비디오 시현
    1-2. 베릴로그 HDL로 탁구대 그리기
    1-3. C++ 로 작성하는 하드웨어 시뮬레이션 테스트벤치
    1-4. 시뮬레이터 빌드
    1-5. Makefile
    1-6. 실습

----------------------------------------------------------------------------

베릴로그 HDL로 화려하지는 않은 비디오 게임기를 만들어본다. 1인 탁구 게임기다. 마이크로 컨트롤러 보드에서 C++언어로 이정도 게임기를 만들기는 그리 어렵지 않을지 모른다. 하지만 이런 동작을 하는 디지털 회로를 설계하기는 좀더 난해할 수 있다.

디지털 회로 설계에도 컴퓨팅 언어가 사용된다. 베릴로그(Verilog)라는 HDL(Hardware Description)언어다. 탁구 게임기가 제대로 묘사 되었는지 확인도 하지 않고 무턱대고 디지털 회로를 꾸밀 수는 없다. 하드웨어는 매우 단단해서 한번 잘못 만들어 놓으면 고치기는 매우 어렵다. 사실 불가능 하다. 디지털 회로를 실제 하드웨어로 꾸미기 전에 먼저 시뮬레이션을 해볼 텐데 프로그래밍 언어 C++와 하드웨어 묘사용 크래스 라이브러리 SystemC 를 사용한다. 동작이 확인 되면 디지털 논리 소자들로 구성된 회로를 FPGA 에 넣어 작동 시켜볼 것이다. 이에 만족하면 한걸음 더 나가 "내 칩"으로 만들어 보기로 하자. 세상에서 유일한 내가 만든 게임기 IC를 내놓을 꿈을 꾸면서 말이다. 나만의 반도체 IC 부품을 제작하려면 큰 비용이 들지만 우리나라 정부에서 학생들에게 무료로 제공하는 "내 칩 제작 서비스"를 통해 "내 칩"을 만들어 볼 수 있다. SOP 28 핀 패키지까지 무료다. 내 유일한 IC를 받아 아래 동영상 처럼 게임기를 만들어 보자. 왼쪽에 노란 딱지가 붙여진 칩이 탁구 게임기 "내 칩"이다. 

1. 탁구대 그리기

비디오 게임기를 목표로 하는 만큼 디지털 그래픽 시현을 알아보자.

1-1. 래스터 스캔 방식 비디오 시현

2차원 평면에 그림을 그리는 방식은 다양하지만 그중 가장 단순하면서 직관적인 것이 래스터 스캔(Raster Scan)이다. 가로와 세로로 좌표를 훑어가며 그림이 될 지점에 점을 찍는다. 그래픽 평면은 가로 128, 세로 64개의 점으로  정했다. 한 화면은 총 8192개의 점으로 구성된다. 공과 패들이 움직이는 모습을 보여 주려면 초당 10개 화면이 만들어져야 한다. 간단해 보이지만 무려 초당 8만 1천여개(=128x64x10)의 점을 쏟아내는 하드웨어를 설계하는 것이 목표다.

1-2. 베릴로그 HDL로 탁구대 그리기

움직이는 화면을 보여주려면 끊임 없이 가로 축 좌표와 세로축 좌표를 생성하고 그림의 위치에 점을 찍어 주어야 한다. 2차원 그래픽의 좌표점을 생성하고 탁구대의 벽을 그리는 하드웨어의 베릴로그 묘사는 다음과 같다. 디지털 회로의 카운터는 클럭을 받아 숫자를 센다. 가로축 좌표 x_pos 를 생성하기 위해 0부터 127까지 센다. 세로축 카운터의 숫자 범위는 0부터 63까지다. 각각 7 비트와 6 비트로 선언되었다.

//
// Filename: pong_SbS.v
// Purpose: Draw Table
//
module pong_SbS(clk, reset, x_pos, y_pos, pixel);
input           clk;
input           reset;
output [6:0]    x_pos;
output [5:0]    y_pos;
output          pixel;

    reg [6:0]   x_pos;
    reg [5:0]   y_pos;
    reg         pixel;
    always @(posedge clk or posedge reset)
    begin
        if (reset)
        begin
            x_pos <= 0;
            y_pos <= 0;
        end
        else
        begin
            x_pos += 1;
            if (x_pos==0)   y_pos += 1;
        end
    end

    assign pixel = (x_pos>9 && x_pos<15)? 1'b1:1'b0;

endmodule

가로 축 x_pos 는 7비트로 선언되었으므로 127(=1111111b)까지 센 후 다음은 0이다. 세로 축 y_pos는 가로 선을 모두 찍고난 x_pos가 0일 때 1씩 증가한다. 6비트 이므로 63(=111111b)다음은 0이다. 세로 축 카운트가 63까지 모두 세면 한 화면의 모두 그려진 셈이다. 가로 축 좌표 10에서 14 사이의 좌표에 점을 찍어 탁구대 벽을 그리는 베릴로그 묘사는 다음과 같다.

    assign pixel = (x_pos>9 && x_pos<15)? 1'b1:1'b0;

1-3. C++ 로 작성하는 하드웨어 시뮬레이션 테스트벤치

탁구대 평면 그림을 그리는 베릴로그를 시뮬레이션 해보자. 테스트 벤치는 SystemC 로 작성 하였다.

//
// Filename: sc_pong_SbS_TB.h
//

#ifndef _SC_PONG_SBS_TB_H_
#define _SC_PONG_SBS_TB_H_

#include <systemc.h>

#ifdef VCD_TRACE_DUT_VERILOG
#include <verilated_vcd_sc.h>
#endif

#include "Vpong_SbS.h"

SC_MODULE(sc_pong_SbS_TB)
{
    sc_clock        clk;
    sc_signal<bool> reset;
    sc_signal<bool> pixel;
    sc_signal<sc_uint<7> > x_pos;
    sc_signal<sc_uint<6> > y_pos;

    Vpong_SbS*  u_pong_SbS;

#ifdef  VCD_TRACE_TEST_TB
    sc_trace_file* fp;  // VCD file
#endif

#ifdef VCD_TRACE_DUT_VERILOG
    VerilatedVcdSc*     tfp;    // Verilator VCD
#endif

    void Test_Gen(void);

    SC_CTOR(sc_pong_SbS_TB):
            clk("clk", 100, SC_NS, 0.5, 0.0, SC_NS, false)
    {
        SC_THREAD(Test_Gen);
        sensitive << clk;

        // Instantiate DUT ----------
        u_pong_SbS = new Vpong_SbS("u_pong_SbS");
        u_pong_SbS->clk(clk);
        u_pong_SbS->reset(reset);
        u_pong_SbS->x_pos(x_pos);
        u_pong_SbS->y_pos(y_pos);
        u_pong_SbS->pixel(pixel);

#ifdef VCD_TRACE_TEST_TB
        // VCD Trace
        fp = sc_create_vcd_trace_file("sc_pong_SbS_TB");
        fp->set_time_unit(100, SC_PS);
        sc_trace(fp, clk,   "clk");
        sc_trace(fp, reset, "reset");
        sc_trace(fp, x_pos, "x_pos");
        sc_trace(fp, y_pos, "y_pos");
        sc_trace(fp, pixel, "pixel");
#endif

#ifdef VCD_TRACE_DUT_VERILOG
        // Trace Verilated Verilog internals
        Verilated::traceEverOn(true);
        tfp = new VerilatedVcdSc;
        sc_start(SC_ZERO_TIME);
        u_pong_SbS->trace(tfp, 99);  // Trace levels of hierarchy
        tfp->open("Vpong_SbS.vcd");
#endif
    }
};
#endif

SystemC는 C++ 로 하드웨어를 묘사하기 위해 만들어진 크래스 라이브러리다. 만일 SystemC가 생소하다면 유튜브 동영상 "Learn SystemC"[링크]를 추천한다. 10여분짜리 6편으로 구성된 이 동영상은 SystemC를 간략하게 핵심만 짚어 설명한다. 프로그래밍 언어를 이해한다면 처음 세편 정도만 들어보는 것으로도 시작하기에 충분하다.

탁구대 그리기를 기술한 베릴로그 pong_SbS.v의 테스트벤치를 살펴보자. C++의 크래스를 사용하기 위해 먼저 SystemC를 인클루드 한다.

#include <systemc.h>

베릴로그 HDL로 묘사된 하드웨어를 SystemC의 테스트벤치로 불러오려면 언어변환이 되어야 한다. 베릴레이터(Verilator)는 오픈-소스 언어 변환기로서 베릴로그를 SystemC의 C++ 크래스로 변환해준다. C++의 SystemC 크래스로 변환된 베릴로그의 파일들은 현재 작업 디렉토리의 obj_drv에 저장된다. 언어 변환한 C++ 헤더 파일 명은 베릴로그 파일 앞에 대문자 V 가 붙는다. SystemC 테스트벤치에서 C++로 변환된 베릴로그의 헤더 파일을 인클루드 한다.

#include "Vpong_SbS.h"

테스트벤치 모듈은 SystemC의 크래스 sc_pong_SbS_TB 다. 크래스와 동일한 이름의 구성자(constructor)가 존재한다.

SC_MODULE(sc_pong_SbS_TB)
{
    SC_CTOR(sc_pong_SbS_TB):
            clk("clk", 100, SC_NS, 0.5, 0.0, SC_NS, false)
    {

        ......

    }
};

구성자가 수행할 주요 임무 중 하나는 사건(event)과 이에 반응할 콜백(call-back) 함수의 지정이다. 클럭 신호 clk에 발생하는 사건에 감응되어 반응할 콜백 함수 Test_Gen() 를 아래와 같이 지정하였다.

        SC_THREAD(Test_Gen);
        sensitive << clk;

시험의 대상 DUT(Design Under Test)는 포인터 크래스로 선언되었다.

    Vpong_SbS*  u_pong_SbS;

DUT를 사례화(instantiate)한 후 지역 하드웨어 객체에 연결(mapping)도 구성자 내에서 이뤄진다.

        // Instantiate DUT ----------
        u_pong_SbS = new Vpong_SbS("u_pong_SbS");
        u_pong_SbS->clk(clk);
        u_pong_SbS->reset(reset);
        u_pong_SbS->x_pos(x_pos);
        u_pong_SbS->y_pos(y_pos);
        u_pong_SbS->pixel(pixel);

모듈 내의 지역 하드웨어 객체(베릴로그의 wire 또는 reg에 해당)들은 미리 선언 되었다.

    sc_clock        clk;
    sc_signal<bool> reset;
    sc_signal<bool> pixel;
    sc_signal<sc_uint<7> > x_pos;
    sc_signal<sc_uint<6> > y_pos;

주기적인 클럭을 선언하는 객체형은 sc_clock 이다. 주기(period)와 비율(duty ratio)등 클럭의 규격은 구성자와 함께 초기화 한다. 테스트벤치의 구성자 SC_CTOR 에서 주기 100 나노 초, 듀티 비 50% 인 클럭 발생기의 초기화 선언은 다음과 같다.

    SC_CTOR(sc_pong_SbS_TB):
              c
lk("clk", 100, SC_NS, 0.5, 0.0, SC_NS, false)

하드웨어 신호 clk에 발생하는 사건에 감응된 콜백 함수 Test_Gen()는 별도의 sc_pong_SbS_TB.cpp에 기술하였다.

//
// Filename: sc_pong_SbS_TB.cpp
//

#include "sc_pong_SbS_TB.h"

void sc_pong_SbS_TB::Test_Gen()
{
    reset.write(true);
    wait(clk.posedge_event());
    wait(clk.posedge_event());
    wait(clk.posedge_event());
    reset.write(false);

    while(true)
    {
        wait(clk.posedge_event());

        if (x_pos.read()==127 && y_pos.read()==63)
        {
            wait(5000, SC_NS);
            sc_stop();
        }
    }
}

콜백 함수의 형식은 사건에 대기 시킬 수 있는 SC_THREAD 다. 사건 또는 시간 지연의 방식으로 테스트 절차를 기술 할 수 있다. 시뮬레이션이 개시되면 DUT에 reset 을 준 후 3회에 걸쳐 clk의 상승 엣지 사건을 기다린 후 리셋을 풀어준다. 이어 while(true)의 무한 반복문으로 이어진다. 실행 선점권을 이 무한 반복문이 점유하지 않도록 wait(clk.posedge_event()) 를 두고 있는 점에 유의한다.

SystemC는 C++다. 변환으로 얻은 하드웨어 DUT와 테스트벤치 크래스를 가지고 실행 파일을 만들려면 main()에 사례화 한다.

/******************************************************************
Filename: sc_main.cpp
Purpose : Testbench
Author  : goodkook@gmail.com
History : Mar. 2025, First release
*******************************************************************/
#include "sc_pong_SbS_TB.h"

int sc_main(int argc, char** argv)
{
    sc_pong_SbS_TB u_sc_pong_SbS_TB("u_sc_pong_SbS_TB");
    sc_start();

    return 0;
}

DUT를 가지고 있는 테스트벤치 크래스를 인클루드 하고 sc_main() 내에 사례화 하였다. sc_main()은 매크로 정의한 것으로 C++ 의 main()이다.

    sc_pong_SbS_TB u_sc_pong_SbS_TB("u_sc_pong_SbS_TB");

하드웨어가 준비되면 비로서 시뮬레이터를 시작한다.

    sc_start();

시뮬레이션은 사건 발생이 없거나 진행 시간이 만료될때까지 지속된다. sc_start()의 인수로 지속 시간을 줄 수 있다. 지속 시간을 지정하지 않았으므로 지속 시간을 무한히 진행된다. sc_clock 으로 클럭 사건이 무한 반복되므로 시뮬레이터는 종료하지 않는다.

1-4. 시뮬레이터 빌드

C++ 컴파일러로 DUT와 테스트벤치를 컴파일 하여 시뮬레이터를 빌드한다. 베릴레이터를 통한 빌드 명령은 다음과 같다.

    $ verilator --sc -Wno-WIDTHTRUNC -Wno-WIDTHEXPAND \
                --trace --timing --pins-sc-uint \
                --top-module pong_SbS  --exe --build \
                -CFLAGS -g -CFLAGS -I../../c_untimed \
                -CFLAGS -I/opt/systemc/include \
                -CFLAGS -DVCD_TRACE_TEST_TB \
                -CFLAGS -DVCD_TRACE_DUT_VERILOG \
                -LDFLAGS -lm -LDFLAGS -lgsl -LDFLAGS -lSDL2  \
                ../pong_SbS/pong_SbS.v \
                ./sc_main.cpp ./sc_pong_SbS_TB.cpp

명령 verilator 는 베릴로그를 C++ 크래스 모듈로 변환하고 SystemC로 작성한 테스트 벤치를 묶어 실행파일로 컴파일 한다. 베릴로그에서 변환된 C++ 소스 파일은 현재 디렉토리에서 하위 디렉토리 obj_dir를 만들어 저장한다. 다수의 C++ 소스 파일을 일일이 컴파일 하고 실행 파일을 생성 하려면 표준 외에 별도의 라이브러리들이 추가되어야 한다. 이를 일괄적으로 처리하기 위한 메이크 스크립트도 이곳에 만들어진다. 메이크 스크립트 ./obj_dir/Vpong_SbS.mk 에 의해 시뮬레이터를 빌드하는 과정은 다음과 같다. 언어 변환 후 컴파일과 링크는 매우 난해하며 그 과정에서 수많은 빌드 옵션이 동원된다. 화면에 뿌려지는 로그들은 매우 난해하다. 어지러운 메시지들을 일일이 살펴볼 필요는 없지만 어떤 절차를 수행하는지 간략히 살펴보기로 한다.

언어변환기, 컴파일-링크 등과 같은 자동화 도구들의 작동을 이해하면 향후 고급 활용이 필요할 때 도움이될 것이다. 자동화 도구의 개선이나 전용 도구 사유화 개발에 참여 할 수도 있다. 자동화 도구의 개발 또한 반도체 산업의 중요한 부분을 차지한다. 무었보다도 설계 오류에 대처할 수 있는 단서가 된다. 게다가 도구를 잘 이해하고 다룰수 있다면 능력치를 뽐내기 매우 좋다!

make: Entering directory './obj_dir'
ccache g++  -I. -MMD ...... \
         -I/usr/local/share/verilator/include \
         -I/usr/local/share/verilator/include/vltstd \
         ......
         -I/opt/systemc/include \
         -DVCD_TRACE_TEST_TB -DVCD_TRACE_DUT_VERILOG \
         -I/opt/systemc/include \
         -Os  -c -o sc_main.o ../sc_main.cpp

ccache g++  -I. -MMD ...... \
         ......
         -Os  -c -o sc_pong_SbS_TB.o ../sc_pong_SbS_TB.cpp

하위 디렉토리 obj_dir로 이동 후 테스트벤치를 컴파일 한다. GNU의 C++ 컴파일러 g++ 를 ccache 에 씌워 실행 하고 있다. 다수의 소스 파일을 독립적으로 컴파일 하여 빌드 시간을 줄일 수 있도록 해준다. 특히 병렬 코어를 가진 CPU에 병렬처리가 가능한 현대적 컴퓨팅 운영체제에서 매우 유용하다. 이어 베릴레이터의 API 소스들을 컴파일 한다. 

ccache g++ -Os  -I.  -MMD ...... \
            ......
            -c -o verilated.o \
             /usr/local/share/verilator/include/verilated.cpp

ccache g++ -Os  -I. \
            ......
            -c -o verilated_vcd_c.o \
            ......
            /usr/local/share/verilator/include/verilated_vcd_c.cpp

ccache g++ -Os  -I.  -MMD ...... \
            ......
            -c -o verilated_threads.o \
            ......
            /usr/local/share/verilator/include/verilated_threads.cpp

베릴로그에서 SystemC로 변환할 때 작은 단위(베릴로그의 always블럭을 SystemC의 사건-콜백 함수)로 분할되었던 C++ 소스들을 한 파일로 묶는다. 이때 베릴레이터에서 제공한 파이썬 스크립트가 사용된다.

python3 /usr/local/share/verilator/bin/verilator_includer \
            ......
            Vpong_SbS.cpp ... > Vpong_SbS__ALL.cpp

베릴로그에서 변환된 C++ 파일을 컴파일 한다.

ccache g++ -Os  -I.  -MMD ...... \
            ......
            -I/opt/systemc/include \
            -c -o Vpong_SbS__ALL.o Vpong_SbS__ALL.cpp

테스트벤치와 베릴레이터 API 그리고 DUT의 오브젝트 파일들을  모두 묶어 한 실행 파일로 합친다. 이때 systemc, pthread 등의 라이브러리가 동원되었다. 각각 컴파일된 오브젝트 파일을 모두 묶는 링크 단계에서 g++의 실행은 ccache 하지 않는다.

g++ -L/opt/systemc/lib \
            sc_main.o \
            sc_pong_SbS_TB.o \
            verilated.o \
            verilated_vcd_c.o \
            verilated_threads.o \
            Vpong_SbS__ALL.a \
            -pthread -lpthread -latomic -lsystemc \
            -o Vpong_SbS

빌드하는 동안 만들어졌던 중간 파일들을 청소하고 작업을 수행 하던 obj_dir 디렉토리를 떠나면서 수행 리포트를 보여준다. 베릴레이터의 버젼, 도구가 작동하면서 점유했던 메모리 량, 수행 시간 등을 보여 준다. 참고로 봐두자.

rm Vpong_SbS__ALL.verilator_deplist.tmp

make: Leaving directory '/obj_dir'

- V e r i l a t i o n   R e p o r t: Verilator 5.045 devel rev v5.044-137-g4aa0ea3f2
- Verilator: Built from 0.029 MB sources in 2 modules, into 0.040 MB in 8 C++ files needing 0.000 MB
- Verilator: Walltime 0.770 s (elab=0.009, cvt=0.022, bld=0.677); cpu 0.019 s on 1 threads; allocated 30.348 MB

1-5. Makefile

시뮬레이터를 빌드하기 위해 사용한 verilator의 명령줄 옵션이 매우 길다. 개발하는 도중 이를 매번 입력하기가 번거롭다. 심지어 이 명령들을 전부 외우고 있기도 어려울 뿐더러 설계마다 달라지는 옵션을 명령줄에 적용할 수 없다. 시간이 지난후 재 빌드할 때 예전의 기억을 모두 더듬기는 불가능하다. 자동화 도구의 명령과 사용된 옵션 그리고 여러 단계의 빌드 절차를 스크립트로 작성해 두면 언재든 재연 할 수 있다. Make 유틸리티는 빌드 절차를 쳬계적으로 기술할 수 있는 유용한 도구다. 탁구 게임기 시뮬레이터를 빌드하는 Makefile 은 다음과 같다.

# SystemC Environments -----------------------------------------
export SYSTEMC = /opt/systemc
export SYSTEMC_HOME = $(SYSTEMC)
export SYSTEMC_INCLUDE = $(SYSTEMC_HOME)/include
export SYSTEMC_LIBDIR = $(SYSTEMC_HOME)/lib
export LD_LIBRARY_PATH :=$(LD_LIBRARY_PATH):$(SYSTEMC_LIBDIR)
export CXX = clang++

#---------------------------------------------------------------
TOP_MODULE = pong_SbS

# SystemC testbench Reuse --------------------------------------
SC_SRCS =  \
./sc_main.cpp \
./sc_$(TOP_MODULE)_TB.cpp
SC_HDRS = \
./sc_$(TOP_MODULE)_TB.h

# Verilator vars -----------------------------------------------
RTL_PATH = \
../$(TOP_MODULE)
lint: VERILOG_SRCS =  \
$(RTL_PATH)/pong_SbS.v
build: VERILOG_SRCS =  \
$(RTL_PATH)/pong_SbS.v
run: VERILOG_SRCS =  \
$(RTL_PATH)/pong_SbS.v

#---------------------------------------------------------------
VERILATOR    = verilator
VL_WARNING   = -Wno-WIDTHTRUNC -Wno-WIDTHEXPAND
VCFLAGS += -CFLAGS -g
VCFLAGS += -CFLAGS -I../../c_untimed
VCFLAGS += -CFLAGS -I$(SYSTEMC_INCLUDE)
ifeq ($(VCD_TRACE),)
VCFLAGS += -CFLAGS -DVCD_TRACE_TEST_TB
VCFLAGS += -CFLAGS -DVCD_TRACE_DUT_VERILOG
endif
#VCFLAGS += -CFLAGS -D$(MODE)
VCFLAGS += -LDFLAGS -lm
VCFLAGS += -LDFLAGS -lgsl
VCFLAGS += -LDFLAGS -lSDL2
#VCFLAGS += -CFLAGS -fPIC
#VCFLAGS += -LDFLAGS -shared

# Targets ------------------------------------------------------
TARGET       = V$(TOP_MODULE)
TARGET_DIR   = obj_dir

# Build Rules --------------------------------------------------

all :
@clear
@if [ ! -n "$(TOP_MODULE)" ];  then \
echo "*********************************"; \
echo "!!! TOP_MODULE not declared !!!"; \
echo "*********************************"; \
exit 1; \
fi
@echo
@echo 'Makefile for Co-Simulation of Verilog-RTL example, $(TOP_MODULE) in MODE=$(MODE)'
@echo
@echo '    TOP_MODULE=$(TOP_MODULE) make lint'
@echo '    TOP_MODULE=$(TOP_MODULE) VCD_TRACE=[YES]|NO make build'
@echo '    make run'
@echo '    make wave'
@echo '    make clean'
@echo
@echo 'CC BY-NC, by GoodKook, goodkook@gmail.com'
@echo

lint :
$(VERILATOR) --sc --timing $(VL_WARNING) --pins-sc-uint \
--top-module $(TOP_MODULE) $(VERILOG_SRCS)

build : $(TARGET_DIR)/$(TARGET)
$(TARGET_DIR)/$(TARGET) : $(VERILOG_SRCS) $(SC_SRCS) $(SC_HDRS)
$(VERILATOR) --sc $(VL_WARNING) --trace --timing --pins-sc-uint \
--top-module $(TOP_MODULE) $(VERILOG_DEF) --exe --build \
$(VCFLAGS) $(VERILOG_SRCS) $(SC_SRCS)

run : $(TARGET_DIR)/$(TARGET)
./$(TARGET_DIR)/$(TARGET)

wave : V$(TOP_MODULE).vcd sc_$(TOP_MODULE)_TB.vcd
gtkwave V$(TOP_MODULE).vcd --save=V$(TOP_MODULE).gtkw &
gtkwave sc_$(TOP_MODULE)_TB.vcd --save=sc_$(TOP_MODULE)_TB.gtkw &

clean :
rm -rf $(TARGET_DIR)
rm -f *.vcd
rm -f $(TOP_MODULE)
rm -f sc_$(TOP_MODULE)_TB.txt

debug : $(TARGET_DIR)/$(TARGET)
ddd $(TARGET_DIR)/$(TARGET)

C++의 표준 크래스 외에 SystemC 라이브러리를 사용하고 있으므로 이에 맞는 환경을 갖춰야 한다. 환경 변수로 파일시스템 내에 SystemC의 헤더 파일과 라이브러리가 존재하는 디렉토리들을 변수로 선언하였다. 환경변수 LD_LIBRARY_PATH는 실행형 동적 라이브러리(마이크로소프트의 윈도우즈 운영체제에서 DLL, Dynamic Linking Library, 리눅스에서는 so, Shared Object)가 존재하는 디렉토리 위치를 지정한다. 시뮬레이터가 실행 될 때 SystemC의 시뮬레이션 엔진(커널)을 필요로 한다. 이때 실행형 동적 라이브러리를 불러오기 위해 탐색할 디렉토리 위치다. 현재 수행중인 스크립트 이외 실행 명령에서도 이 환경변수를 유효화 하기 위해 export 하였다.

# SystemC Environments -----------------------------------------
export SYSTEMC = /opt/systemc
export SYSTEMC_HOME = $(SYSTEMC)
export SYSTEMC_INCLUDE = $(SYSTEMC_HOME)/include
export SYSTEMC_LIBDIR = $(SYSTEMC_HOME)/lib
export LD_LIBRARY_PATH :=$(LD_LIBRARY_PATH):$(SYSTEMC_LIBDIR)
export CXX = clang++

베릴레이터 도구의 다양한 명령줄 옵션들을 모두 Makefile 내에서 사용할 변수로 지정하였다. 변수 할당에 += 는 이어붙이기(concatenation)다. 베릴레이터의 실행 옵션은 [링크]에서 확인할 수 있다.

VERILATOR    = verilator
VL_WARNING   = -Wno-WIDTHTRUNC -Wno-WIDTHEXPAND
VCFLAGS += -CFLAGS -g
VCFLAGS += -CFLAGS -I../../c_untimed
VCFLAGS += -CFLAGS -I$(SYSTEMC_INCLUDE)
ifeq ($(VCD_TRACE), )
VCFLAGS += -CFLAGS -DVCD_TRACE_TEST_TB
VCFLAGS += -CFLAGS -DVCD_TRACE_DUT_VERILOG
endif
VCFLAGS += -LDFLAGS -lm
VCFLAGS += -LDFLAGS -lgsl
VCFLAGS += -LDFLAGS -lSDL2

Makefile 은 스크립트오서 언어에 준하는 명령 체계를 갖추고 있다. 환경 변수를 문자열로 비교하는 명령은 ifeq 이다. 환경 변수 VCD_TRACE 의 문자열 값이 YES인 경우 변수 VCFLAGS에 문자열을 추가하는 명령문은 다음과 같다.

ifeq ($(VCD_TRACE), YES)
VCFLAGS += -CFLAGS -DVCD_TRACE_TEST_TB
VCFLAGS += -CFLAGS -DVCD_TRACE_DUT_VERILOG
endif

다른 인수 없이 make 를 실행 하면 현재 디렉토리에서 Makefile 을 찾아 이를 처리한다. 기본 처리 목표(target)은 all: 이다. 위의 Makefile에서 목표 all 은 도움말을 출력하도록 작성되었다.

all :
  @clear
  @echo
  @echo 'Makefile for Co-Simulation of Verilog-RTL example,  \
                $(TOP_MODULE) in MODE=$(MODE)'
  @echo
  @echo '    TOP_MODULE=$(TOP_MODULE) make lint'
    @echo '    TOP_MODULE=$(TOP_MODULE) VCD_TRACE=[YES]|NO make build'
    @echo '    make run'
    @echo '    make wave'
    @echo '    make clean'
    @echo
    @echo 'CC BY-NC, by GoodKook, goodkook@gmail.com'
    @echo

위의 Makefile에는 별도로 여러 목표를 지정하고 있다. 목표 lint는 베릴로그의 무결성을 검사한다. 베릴레이터는 베릴로그를 읽어 C++로 변환해 주는 도구로서 무결성 검사를 매우 효과적으로 수행한다. 달러 문자와 소괄호 $() 사이에 앞서 선언한 변수를 지정하여 해당 문자열 값을 불러온다. 백슬러쉬 문자는 명령줄이 연속됨을 뜻한다.

lint :
$(VERILATOR) --sc --timing $(VL_WARNING) --pins-sc-uint \
--top-module $(TOP_MODULE) $(VERILOG_SRCS)

목표 build 는 베릴로그에서 변환한 모듈과 테스트벤치를 합쳐 C++ 컴파일러로 시뮬레이터를 빌드한다.

build : $(TARGET_DIR)/$(TARGET)

Makefile에서 콜론(:)은 매우 중요한 의미를 가진다. 왼편의 목표에 도달하려면 오른편의 파일이 존재해야 하는 의존 관계를 표현한 것이다. 앞서 변수 TARGET_DIR과 TARGET 이 각각 obj_dir과 Vpong_SbS 이었다. 따라서 ./obj_dir/Vpong_SbS 가 없다면 이를 만드는 규칙을 찾는다.

$(TARGET_DIR)/$(TARGET) : $(VERILOG_SRCS) $(SC_SRCS) $(SC_HDRS)
$(VERILATOR) --sc $(VL_WARNING) --trace --timing --pins-sc-uint \
--top-module $(TOP_MODULE) $(VERILOG_DEF) --exe --build \
$(VCFLAGS) $(VERILOG_SRCS) $(SC_SRCS)

목표 $(TARGET_DIR)/$(TARGET)는 콜론의 오른편에 놓은 파일들에 의존하고 있다. 의존관계에 있는 파일들을 재료로 삼아 왼편의 파일을 만드는 규칙은 바로 이어지는 명령에 따른다. 위의 예에서 각 변수를 앞서 선언한 변수들에 대입해 보면 결국 베릴레이터로 소스 파일들을 읽어 시뮬레이터를 빌드하는 긴 명령임을 알 수 있다. Makefile의 목표는 빌드 뿐만 아니라 다양한 규칙을 기술할 수 있다. 목표 run 은 ./obj_dir/Vpong_SbS 가 만들어 졌는지 확인하고 이를 실행 한다.

run : $(TARGET_DIR)/$(TARGET)
  ./$(TARGET_DIR)/$(TARGET)

시뮬레이션을 수행하여 얻은 VCD 파일을 디지털 파형 보기 유틸리티 gtkwave 로 보기위한 목표는 wave 에 작성 되었다.

wave : V$(TOP_MODULE).vcd sc_$(TOP_MODULE)_TB.vcd
  gtkwave V$(TOP_MODULE).vcd --save=V$(TOP_MODULE).gtkw &
  gtkwave sc_$(TOP_MODULE)_TB.vcd --save=sc_$(TOP_MODULE)_TB.gtkw &


1-6. 실습

앞서 설명한 예제의 탁구대 그리기 베릴로그와 SystemC 테스트벤치 그리고 Makefile은 깃 허브 저장소[링크]에서 올려져 있다. 향후 합성으로 변환될 베릴로그와 테스트벤치 파일을 각각 다른 디렉토리에 용도별로 나누어 저장해 놓았다. 예제의 디렉토리 구조는 다음과 같다.

    $ tree .
    .
    ├── _Docs_
    ├── pong_SbS
    │   └── pong_SbS.v
    └── simulation
        ├── Makefile
        ├── sc_main.cpp
        ├── sc_pong_SbS_TB.cpp
        ├── sc_pong_SbS_TB.gtkw
        └── sc_pong_SbS_TB.h

시뮬레이션 디렉토리로 이동하여 make를 실행하면 현재 디렉토리의 Makefile에 목표 all을 작성한 대로 다음과 같은 도움말을 출력한다. 

    $ cd ~/ETRI050_DesignKit/Projects/RTL/pong_SbS/01_Table/simulation
    $ make

    Makefile for Co-Simulation of Verilog-RTL example,

    TOP_MODULE=pong_SbS VCD_TRACE=[YES]|NO make build
    make run
    make wave
    make clean
    CC BY-NC, by GoodKook, goodkook@gmail.com

리눅스 명령줄에 make를 실행하기 전에 특정 환경 변수를 별도로 넣을 수 있다. 예제의 Makefile 에 VCD_TRACE 변수 조건을 아래와 같이 두었다.

ifeq ($(VCD_TRACE),)
VCFLAGS += -CFLAGS -DVCD_TRACE_TEST_TB
VCFLAGS += -CFLAGS -DVCD_TRACE_DUT_VERILOG
endif

시뮬레이터를 빌드 하면서 VCD 파일 생성을 원치 않는다면 VCD_TRACE 변수를 NO로 해준다.

    $ VCD_TRACE=NO make build

변수 VCD_TRACE 를 비워두고 시뮬레이터 빌드 한다.

    $ make build

성공적으로 빌드가 이뤄지면 ./obj_dir 디렉토리에 실행파일이 만들어진다.

    $ ls -l ./obj_dir/Vpong_SbS

    -rwxr-xr-x 1 mychip mychip 4944776 Feb 28 19:03 ./obj_dir/Vpong_SbS

시뮬레이터를 실행한다.

    $ make run

    ./obj_dir/Vpong_SbS

        SystemC 3.0.2-Accellera --- Feb  5 2026 16:58:41
        Copyright (c) 1996-2025 by all Contributors,
        ALL RIGHTS RESERVED

    Info: (I703) tracing timescale unit set: 100 ps (sc_pong_SbS_TB.vcd)

    Info: /OSCI/SystemC: Simulation stopped by user.

 SystemC 테스트벤치 sc_pong_SbS_TB.h 를 컴파일 할 때 VCD 파일을 생성하도록 C++ 컴파일러의 명령줄 옵션으로 -DVCD_TRACE_TEST_TB 와 -DVCD_TRACE_DUT_VERILOG가 전달 되었으므로 시뮬레이션의 결과가 VCD 파일에 기록된다.

    $ ls -l *.vcd

    -rw-r--r-- 1 mychip mychip 395818 Feb 28 19:05 sc_pong_SbS_TB.vcd
    -rw-r--r-- 1 mychip mychip 294923 Feb 28 19:05 Vpong_SbS.vcd

디지털 파형을 보자.

    $ make wave

탁구대의 128x64 개의 픽셀로 구성된 한 화면 분량의 시뮬레이션 결과다. 8192개에 이르는 클럭 clk 가 한 화면에 담겼다. 탁구대를 그리기 위한 베릴로그의 동작은 가로 좌표 x_pos 와 세로 좌표 y_pos 의 생성이다. 파형의 일부분을을 확대하여 카운터 동작을 살펴보자.

x_pos가 0에서 127까지 증가하며 가로 좌표를 출력하고 있다. x_pos=0 일때 세로 좌표 y_pos가 증가하는 모습을 볼 수 있다.

가로 좌표가 10에서 14 사이에 pixel이 1을 출력하여 화면에 점을 찍는다. 세로 좌표 값이 63 에 이르면 다시 0부터 카운트를 시작하여 화면 그림을 재생산한다. 래스터 스캔 방식의 그림 그리기 동작을 수행하고 있는 것을 알 수 있다.

탁구 게임기의 비디오 화면을 확인하기 위해 한 화면에 해당하는 8천여개의 클럭 신호를 일일이 확인하는 것은 불합리 하다. 디지털 회로의 시뮬레이션 결과를 설계 목적에 맞게 그래픽 화면으로 보면서 확인할 수 있는 방법을 찾아보자.