제약 난수 검증(Constrained Random Verification)은 DUT(시험 대상 장치)에 대한 유사 난수 트랜잭션 생성에 의존하는 테스트벤치 전략. 목표는 DUT와의 무작위 상호작용을 통해 미리 정의된 다수 이벤트의 기능적 커버리지(functional coverage) 달성.
OSVVM(Open Source VHDL Verification Methodology)은 제약 난수 테스트벤치 생성에 편리한 다수 패키지 포함된 무료 VHDL 라이브러리. 특히 RandomPkg와 CoveragePck에 관심, 이 글에서 사용. OSVVM GitHub 페이지 방문해 라이브러리 기능 더 알아보시길 권장.
시험 대상 장치(DUT)
이전 블로그 기사에서 링 버퍼 FIFO 만들었지만 제대로 된 자가 검사 테스트벤치로 모듈 정확성 검증 안 했습니다. 이번에 링 버퍼 FIFO용 제대로 된 테스트벤치를 제약 난수 검증으로 만듭니다.
entity ring_buffer is generic ( RAM_WIDTH : natural; RAM_DEPTH : natural ); port ( clk : in std_logic; rst : in std_logic; -- Write port wr_en : in std_logic; wr_data : in std_logic_vector(RAM_WIDTH - 1 downto 0); -- Read port rd_en : in std_logic; rd_valid : out std_logic; rd_data : out std_logic_vector(RAM_WIDTH - 1 downto 0); -- Flags empty : out std_logic; empty_next : out std_logic; full : out std_logic; full_next : out std_logic; -- The number of elements in the FIFO fill_count : out integer range RAM_DEPTH - 1 downto 0 ); end ring_buffer;
위 코드는 링 버퍼 모듈 엔티티. DUT를 블랙박스로 다룰 예정, 즉 DUT 구현 방식 어떤 지식도 가정 안 함. 이 글은 링 버퍼 FIFO 아닌 테스트벤치에 관한 것이니까요.
테스트벤치에서 엔티티 인스턴스화 방식으로 DUT 인스턴스화. 인스턴스화는 사소해 생략, 나중에 기사에서 다운로드 가능.
DUT 제네릭은 다음 값으로 매핑:
RAM_WIDTH: 16
RAM_DEPTH: 256
테스트벤치 전략
구현 전 테스트 전략 검토. 아래 이미지는 곧 만들 테스트벤치 주요 컨셉 보여줌.
DUT 입력 측에서 랜덤 쓰기 트랜잭션 수행. 입력 데이터 매 클럭 사이클마다 랜덤 값으로 설정, 쓰기 인에이블 스트로브는 랜덤 지속시간.
마찬가지로 랜덤 읽기 수행. 읽기 인에이블 신호를 랜덤 클럭 사이클 수 동안 버스트로 어설트.
DUT와 병렬로 행동 모델(behavioral model) 배치. 이는 DUT에 쓰인 링 버퍼와 다르게 구현되었지만 같은 인터페이스 가진 FIFO. DUT와 달리 행동 모델은 합성 가능할 필요 없어 고급 VHDL 프로그래밍 기능 자유롭게 사용 가능.
별도 프로세스에서 DUT 출력과 행동 모델 출력 매 클럭 사이클 비교, assert 문 사용. 두 FIFO 구현이 언제든 다르게 동작하면 어설션 실패로 시뮬레이션 에러와 함께 종료.
마지막으로 DUT로 주고받는 트랜잭션 관찰해 기능 커버리지 데이터 수집. 기능 커버리지 포인트 예: 동시 읽기·쓰기, FIFO가 최소 한 번 채워짐 등. 메인 테스트벤치 시퀀서 프로세스에서 이 이벤트들 모니터링. 우리가 모니터링하는 모든 기능 커버리지 이벤트 발생 시 시뮬레이션 정지.
OSVVM 라이브러리 임포트
OSVVM 라이브러리는 VHDL-2008 지원 시뮬레이터면 모두 사용 가능. 시뮬레이터 기본 제공 라이브러리에 이미 포함된 경우 많음. Mentor Graphics에서 무료 다운로드 가능한 ModelSim PE Student Edition에 포함.
ModelSim에 포함된 OSVVM은 구버전이지만 우리 용도엔 충분. 그냥 랜덤과 커버리지 패키지 임포트:
library osvvm; use osvvm.RandomPkg.all; use osvvm.CoveragePkg.all;
최신 OSVVM 라이브러리는 GitHub 페이지에서 항상 다운로드 가능. 시뮬레이터에 없거나 최신 기능 쓰고 싶으면 거기서 받으세요.
OSVVM 변수 선언
OSVVM 라이브러리는 보호 타입(protected type) 포함 패키지들 가짐. 이 타입들로 생성된 변수들은 정의된 프로세스 범위로 한정됨. 그래서 대신 테스트벤치 아키텍처 선언부에 공유 변수(shared variable)로 선언, 아래 코드 참조.
rv 변수(RandomPType 타입)는 난수 생성용. 모든 난수 생성 필요 프로세스에서 같은 객체 쓰므로 하나만 필요. 마지막 줄은 CovPType 타입 변수 6개 선언.
커버리지 목표 6개라 6개 커버리지 변수 선언, 이 객체들을 '빈(bin)'이라 부름. 공유 변수 사용 전 커버리지 데이터 수집 위해 초기화 필요. 각 CovPType 빈에 AddBins 프로시저 호출로 초기화.
-- Set up coverage bins bin1.AddBins("Write while empty", ONE_BIN); bin2.AddBins("Read while full", ONE_BIN); bin3.AddBins("Read and write while almost empty", ONE_BIN); bin4.AddBins("Read and write while almost full", ONE_BIN); bin5.AddBins("Read without write when almost empty", ONE_BIN); bin6.AddBins("Write without read when almost full", ONE_BIN);
AddBins 프로시저 첫 매개변수로 커버리지 빈 문자열 설명 제공. 이 문자열은 시뮬레이션 끝날 때 각 커버리지 빈 통계 출력할 때 재등장. 텍스트 설명에서 보듯 아주 구체적인 코너 케이스 발생 여부 체크용으로 빈 사용.
AddBins는 빈 변수 내 여러 스코어보드 생성 위해 오버로드된 프로시저. 하지만 빈당 스코어보드 하나만 가질 예정. 그래서 편의 상수 ONE_BIN을 AddBins 프로시저 매개변수로 제공. 이렇게 하면 CovPType 변수들이 빈 하나씩으로 초기화. 빈들이 대표하는 스코어보드들은 모니터링하는 이벤트가 최소 한 번 발생하면 커버된 것으로 간주.
랜덤 입력 생성
DUT에 입력 데이터 생성하는 프로세스부터 만들기. 링 버퍼 FIFO는 시도된 오버라이트와 오버리드 무시하도록 설계. 그래서 단순히 랜덤 데이터를 랜덤 지속시간 버스트로 쓰면 됨. DUT가 실제로 데이터 흡수 준비됐는지 고민 불필요.
PROC_WRITE : process begin wr_en <= rv.RandSlv(1)(1) and not rst; for i in 0 to rv.RandInt(0, 2 * RAM_DEPTH) loop wr_data <= rv.RandSlv(RAM_WIDTH); wait until rising_edge(clk); end loop; end process;
이 프로세스가 고려하는 유일한 건 DUT가 리셋 상태 아님. 프로세스 첫 줄에서 쓰기 인에이블 신호 랜덤 인에이블/디스에이블, 하지만 rst가 '0'일 때만 인에이블.
이후 for-루프가 랜덤 클럭 사이클 수 동안 랜덤 데이터 DUT에 쓰기, 인에이블 신호 활성 아니어도 씀. DUT가 wr_en 신호 '1' 아닐 때 wr_data 포트 무시하도록 설계되었으므로 가능. for-루프 후 프로세스 처음으로 루프백, 또 다른 랜덤 쓰기 트랜잭션 트리거.
랜덤 읽기 수행
DUT에서 데이터 읽는 프로세스도 쓰기 프로세스와 유사. DUT가 비었을 때 읽기 시도 무시하도록 설계되어 rd_en 신호 아무 때나 랜덤 활성화 가능. rd_data 포트에 나타나는 데이터는 실제 검증 안 됨. 이 프로세스는 읽기 인에이블 신호만 제어.
PROC_READ : process begin rd_en <= rv.RandSlv(1)(1) and not rst; for i in 0 to rv.RandInt(0, 2 * RAM_DEPTH) loop wait until rising_edge(clk); end loop; end process;
행동 검증
테스트벤치 내에서 DUT 행동 모델 구성해 동작 검증. 잘 알려진 테스트벤치 전략. 먼저 행동 모델에 DUT와 동시에 같은 입력 공급. 그럼 두 출력 비교해 DUT 올바른 동작인지 확인 가능.
위 이미지는 그런 테스트벤치 기본 레이아웃. 행동 모델이 DUT와 병렬로 동작. DUT 출력 검증 위한 청사진으로 사용.
테스트벤치 FIFO
행동 모델 구현에 연결 리스트(linked list) 사용. 연결 리스트는 합성 불가능하지만 테스트벤치에 완벽. 블로그 정기 구독자면 VHDL에서 연결 리스트 만들기 기사 기억하실 수도. 그 코드 써서 링 버퍼 FIFO 행동 모델 구현.
package DataStructures is type LinkedList is protected procedure Push(constant Data : in integer); impure function Pop return integer; impure function IsEmpty return boolean; end protected; end package DataStructures;
위 코드는 연결 리스트 FIFO 패키지 선언. 보호 타입으로 Push, Pop, IsEmpty 세 함수 가짐. FIFO에 요소 추가·제거, 요소 제로인지 확인용.
-- Testbench FIFO that emulates the DUT shared variable fifo : LinkedList;
보호 타입은 VHDL 클래스류 구성물. 선언부에 공유 변수 선언으로 연결 리스트 객체 생성(위 코드 참조).
행동 모델
DUT 출력 신호 미러링하는 두 새 신호 선언으로 링 버퍼 FIFO 동작 완전 에뮬레이션. 첫 신호는 행동 모델 출력 데이터, 두 번째는 연관된 valid 신호.
-- Testbench FIFO signals signal fifo_out : integer; signal fifo_out_valid : std_logic := '0';
위 코드는 행동 모델 두 출력 신호 선언. 행동 모델 전용 입력 신호 필요 없음, DUT 연결된 것과 동일하니까. DUT 출력 에뮬레이트용으로 시그널 쓰는 건 커버리지 데이터 쉽게 수집하게 하려고(나중에 봄).
PROC_BEHAVIORAL_MODEL : process begin wait until rising_edge(clk) and rst = '0'; -- Emulate a write if wr_en = '1' and full = '0' then fifo.Push(to_integer(unsigned(wr_data))); report "Push " & integer'image(to_integer(unsigned(wr_data))); end if; -- Emulate a read if rd_en = '1' and empty = '0' then fifo_out <= fifo.Pop; fifo_out_valid <= '1'; else fifo_out_valid <= '0'; end if; end process;
위 코드는 링 버퍼 FIFO 행동 모델 구현 프로세스. 리셋 신호 활성 아닐 때 매 라이징 클럭 에지에서 트리거.
행동 모델은 wr_en 어설트되고 full이 '0'일 때 테스트벤치 FIFO에 새 값 푸시. 마찬가지로 행동 모델 읽기 로직은 rd_en과 empty 신호 리스닝으로 작동. 후자는 DUT가 제어하지만 다음 프로세스에서 검증 예정.
출력 검사
DUT 출력 검사 책임지는 모든 로직을 'PROC_VERIFY' 프로세스에 모음. DUT 출력이 행동 모델과 일치하는지 assert 문으로 검사. DUT와 행동 모델이 FIFO 비었을 때 동의하는지도 검사.
검증 프로세스 코드 아래:
PROC_VERIFY : process begin wait until rising_edge(clk) and rst = '0'; -- Check that DUT and TB FIFO are reporting empty simultaneously assert (empty = '1' and fifo.IsEmpty) or (empty = '0' and not fifo.IsEmpty) report "empty=" & std_logic'image(empty) & " while fifo.IsEmpty=" & boolean'image(fifo.IsEmpty) severity failure; -- Check that the valid signals are matching assert rd_valid = fifo_out_valid report "rd_valid=" & std_logic'image(rd_valid) & " while fifo_out_valid=" & std_logic'image(fifo_out_valid) severity failure; -- Check that the output from the DUT matches the TB FIFO if rd_valid then assert fifo_out = to_integer(unsigned(rd_data)) report "rd_data=" & integer'image(to_integer(unsigned(rd_data))) & " while fifo_out=" & integer'image(fifo_out) severity failure; report "Pop " & integer'image(fifo_out); end if; end process;
프로세스는 라이징 클럭 에지로 트리거(첫 코드 라인 참조). DUT는 클럭된 프로세스, DUT에 연결된 다운스트림 로직도 클럭 신호에 동기식으로 예상. 따라서 출력 검사 라이징 클럭 에지에서 하는 게 타당.
두 번째 코드 블록은 DUT에서 오는 empty 신호가 테스트벤치 FIFO 비었을 때만 어설트되는지 검사. DUT와 행동 모델 둘 다 FIFO 비었거나 안 비었음에 동의해야 테스트 통과.
그다음 읽기 데이터 valid 신호 비교. DUT와 행동 모델이 동시에 데이터 출력해야 함, 아니면 뭔가 잘못.
마지막으로 DUT 출력 데이터가 테스트벤치 FIFO에서 팝할 다음 요소와 일치하는지 검사. 이건 rd_valid 신호 어설트되어 rd_data 샘플링 가능할 때만 발생.
커버리지 데이터 수집
테스트벤치 메인 플로우 제어할 시퀀서 프로세스 생성. 이 프로세스가 커버리지 빈 초기화, 테스트 실행, 모든 커버리지 목표 달성 시 테스트벤치 정지. 아래 코드가 완전한 'PROC_SEQUENCER' 프로세스.
PROC_SEQUENCER : process begin -- Set up coverage bins bin1.AddBins("Write while empty", ONE_BIN); bin2.AddBins("Read while full", ONE_BIN); bin3.AddBins("Read and write while almost empty", ONE_BIN); bin4.AddBins("Read and write while almost full", ONE_BIN); bin5.AddBins("Read without write when almost empty", ONE_BIN); bin6.AddBins("Write without read when almost full", ONE_BIN); wait until rising_edge(clk); wait until rising_edge(clk); rst <= '0'; wait until rising_edge(clk); loop wait until rising_edge(clk); -- Collect coverage data bin1.ICover(to_integer(wr_en = '1' and empty = '1')); bin2.ICover(to_integer(rd_en = '1' and full = '1')); bin3.ICover(to_integer(rd_en = '1' and wr_en = '1' and empty = '0' and empty_next = '1')); bin4.ICover(to_integer(rd_en = '1' and wr_en = '1' and full = '0' and full_next = '1')); bin5.ICover(to_integer(rd_en = '1' and wr_en = '0' and empty = '0' and empty_next = '1')); bin6.ICover(to_integer(rd_en = '0' and wr_en = '1' and full = '0' and full_next = '1')); -- Stop the test when all coverage goals have been met exit when bin1.IsCovered and bin2.IsCovered and bin3.IsCovered and bin4.IsCovered and bin5.IsCovered and bin6.IsCovered; end loop; report("Coverage goals met"); -- Make sure that the DUT is empty before terminating the test wr_en <= force '0'; rd_en <= force '1'; loop wait until rising_edge(clk); exit when empty = '1'; end loop; -- Print coverage data bin1.WriteBin; bin2.WriteBin; bin3.WriteBin; bin4.WriteBin; bin5.WriteBin; bin6.WriteBin; finish; end process;
먼저 각 빈에 AddBins 프로시저 호출로 커버리지 빈 객체 초기화(앞서 논의). 리셋 해제 후 커버리지 데이터 수집. 매 라이징 클럭 에지에서 루프 안 코드 실행.
루프 안 첫 코드 블록이 커버리지 데이터 수집용. 빈에 ICover 프로시저 호출로 해당 커버리지 포인트 히트 기록. 정수 매개변수 0 제공하면 호출 효과 없음. 1 사용하면 히트로 카운트.
각 커버리지 빈 객체 안에 ONE_BIN 상수로 초기화했으므로 빈 하나만 있음. 이 단일 빈은 ICover(1) 호출로 도달 가능. 불리언 표현식을 to_integer 함수로 정수 1 또는 0 변환해 커버리지 포인트 히트/미스 기록.
커버리지 데이터 기록 후 IsCovered 함수로 모든 커버리지 목표 달성 여부 확인. 모든 목표 달성되면 루프 종료.
테스트 종료 전 DUT 비어있는지 확인. 작성자와 독자 프로세스 제어권 뺏어 wr_en을 '0'으로, rd_en을 '1'으로 강제(forcing)해 달성.
마지막으로 각 커버리지 빈에 WriteBin 함수 호출로 각 커버리지 목표 히트 횟수 통계 출력. 프로세스 끝 finish 키워드로 시뮬레이터 시뮬레이션 정지.
테스트벤치 실행
아래 양식으로 ModelSim 프로젝트 전체(VHDL 파일 모두) 다운로드 가능.
포함된 do-file 실행해 프로젝트 로드 후 ModelSim 콘솔에 'runtb' 입력으로 테스트벤치 간단 실행. 테스트벤치 런타임은 커버리지 목표 랜덤 히트라 랜덤. 하지만 테스트 결과 재현 가능 - 실제로는 유사 난수 시퀀스 사용하니까.
코드에서 시드 초기화 안 해 기본 시드 값이 유사 난수 생성기에 사용. RandomPType 객체에 InitSeed 프로시저 호출로 다른 시드 설정 가능, 다른 랜덤 시퀀스 생성.
콘솔 출력
ModelSim 콘솔에 'runtb' 명령 후 출력되는 내용 아래. 시뮬레이션 실행 중 테스트벤치 FIFO로 랜덤 푸시/팝 대량 발생.
VSIM 2> runtb # ** Warning: Design size of 15929 statements or 2 leaf instances exceeds # ModelSim PE Student Edition recommended capacity. # Expect performance to be quite adversely affected. # ** Note: Push 34910 # Time: 790 ns Iteration: 0 Instance: /ring_buffer_tb ... # ** Note: Pop 37937 # Time: 83100 ns Iteration: 0 Instance: /ring_buffer_tb # ** Note: Pop 13898 # Time: 83110 ns Iteration: 0 Instance: /ring_buffer_tb # %% WriteBin: # %% Write while empty Bin:(1) Count = 2 AtLeast = 1 # # %% WriteBin: # %% Read while full Bin:(1) Count = 3 AtLeast = 1 # # %% WriteBin: # %% Read and write while almost empty Bin:(1) Count = 106 AtLeast = 1 # # %% WriteBin: # %% Read and write while almost full Bin:(1) Count = 1 AtLeast = 1 # # %% WriteBin: # %% Read without write when almost empty Bin:(1) Count = 1 AtLeast = 1 # # %% WriteBin: # %% Write without read when almost full Bin:(1) Count = 3 AtLeast = 1 # # Break in Process PROC_SEQUENCER at C:/crv/ring_buffer_tb.vhd line 127
모든 커버리지 목표 달성 시 모든 빈 통계 출력. 일부 빈은 한 번만 히트, 하나는 106번 히트. 하지만 결국 각 빈 최소 한 번씩 히트. 따라서 우리가 커버리지 빈으로 정의한 모든 이벤트 테스트되고 검증됨 알 수 있음.
파형
파형 살펴봐 테스트벤치 동작 파악. 아래 이미지는 fill_count 신호 아날로그 값으로 표현한 파형. 이 신호 트레이스 상단일 때 FIFO 풀, 하단일 때 빈.
파형에서 보듯 링 버퍼가 랜덤하게 채워지고 비워짐. 그런데 우리가 필 레벨 이런 상승·하강 명시적 프로그래밍 안 했음. 그래도 DUT와의 유기적 상호작용 패턴 보임.
제약 난수 검증 더 알아보기
제약 난수 검증은 테스트 벡터 순열 너무 많아 철저한 테스트 비현실적일 때 좋은 테스트벤치 전략. 랜덤 상호작용이 지향적 코너 케이스 테스트보다 더 자연스러운 행동 보이면서 정확성 희생 안 함.
커버리지 데이터 수집 올바르게 설정했으면 모든 코너 케이스 충족됨 확신 가능. 추가 이득: 랜덤 테스팅이 구체적으로 테스트하지 않는 DUT 약점 노출 가능성 더 높음. 모든 코너 케이스 알면 지향적 테스트 만들 수 있지만 코너 케이스 쉽게 간과되기 마련, 그때 제약 난수 검증 방법론이 빛남.
이 글은 제약 난수 검증 가능성의 표면만 긁음. OSVVM GitHub 페이지 문서 읽고 주제 더 깊이 파보시길 권장.
SynthWorks의 Advanced VHDL Testbenches and Verification 코스도 추천(필자 무관). 필자 이 물리 코스 5일 버전 수강. 강사 Jim Lewis는 VHDL Analysis and Standardization Group(VASG) 의장. VHDL 테스트벤치 수준 올리고 싶은 회사에 훌륭한 투자.