이번 편에서도 마이크로컨트롤러(uC)와 필드 프로그래머블 게이트 어레이(FPGA) 간 인터페이스를 계속 알아볼 것입니다.
-
1부에서는 대규모 시스템 개발의 방향을 제시하는 베릴로그 설계 철학에 대해 소개하고 있습니다. 이 글은 클록 경계, 스트로브 사용, 그리고 이중 버퍼의 필요성과 같은 레지스터 전송 수준(RTL)의 설계 지침을 다루는 매우 중요한 부분입니다.
-
2부에서는 SPI 프로토콜에 대해 소개하고 있습니다. 이 프로토콜은 데이터 무결성을 보장하기 위해 가변 페이로드 길이와 순환 중복 검사(CRC)와 같은 컨셉을 사용하는 802.3 이더넷 프레임에서 변형된 것입니다.
-
3부에서는 상위 수준에서 본 uC에서 FPGA로의 인터페이스에 대해 소개하고 있습니다. 해당 글에서 가장 중요한 부분은 블록 다이어그램으로, 여러분의 이해를 돕기 위해 그림 1에 다시 제시하였습니다.
이번 편에서는 블록 다이어그램의 오른쪽 상단 영역, 특히 확장 가능한 더블 버퍼의 동작 방식에 초점을 맞추고 있습니다.
그림 1: FPGA의 데이터 흐름을 보여주는 최상위 계층 블록 다이어그램.
PWM 재검토
이중 버퍼를 소개하기에 앞서, 베릴로그 기반 펄스 폭 변조기(PWM)의 동작에 대해 간단히 살펴보겠습니다. 이 부분이 중요한 이유는, 이중 버퍼를 PWM과 같은 하드웨어와의 주소 지정이 가능한 인터페이스로 이해하는 것이 가장 효과적이기 때문입니다.
PWM 모듈의 최상위 계층 인터페이스가 아래 베릴로그 코드에 설명되어 있습니다. 이 모듈은 비트 폭과 최소 및 최대 듀티 사이클 제한 값을 설정하기 위해 매개변수를 사용한다는 점에 주목하십시오. 또한, PWM 모듈은 듀티 사이클의 설정에 [B - 1:0] 입력 벡터(vector)를 사용합니다. 코드에 명시되어 있지는 않지만, 각 PWM 듀티 사이클의 시작 시점에 입력을 읽습니다.
module PWM #(parameter
B = 12, // implies a 24.4 kHz PWM assuming a 100 MHz system clock
D_MIN_PERCENT = 0,
D_MAX_PERCENT = 95
)
(
input wire clk,
input wire enable,
input wire [B - 1:0] d_in,
output reg PWM,
output reg [B - 1:0] cnt
);
데이터의 동시 처리
그림 1에서 볼 수 있듯이, 이 PWM은 상위 구조인 uC에서 FPGA로의 SPI 시스템 내에서 동작하도록 설계되어 있습니다. SPI는 기본적으로 1 바이트 크기의 데이터 단위로 동작하므로, B로 정의된 데이터 폭을 사용하는 PWM과는 뚜렷한 차이를 보입니다. 편의상, PWM이 16비트의 비트 폭(B)으로 인스턴스화되어 있다고 가정하겠습니다.
시스템이 PWM의 입력과 관련된 레지스터들을 업데이트할 때 문제가 발생합니다. 주의 깊게 살피지 않으면, PWM이 업데이트 도중 읽기 동작을 수행할 수도 있습니다. 그 결과로, PWM을 구동 하는 바이트가 하나는 이전 값이고 다른 하나는 새로운 값이 되어, 한 번의 PWM 주기 동안 듀티 사이클이 급격하게 튀는 현상이 발생합니다. PWM이 LED 표시용으로 사용되었다면 이 현상이 눈에 띄지 않을 수 있지만, 보다 복잡한 시스템에서 이런 오류는 강한 임펄스로 작용하여 발생 시점과 빈도에 따라 시스템이 링잉하거나 불안정해질 수 있습니다.
이 문제의 해결책은 앞선 글들에서 언급한 이중 버퍼 방식을 구현하는 것으로, 이에 대해서는 아래에서 더 자세히 설명하겠습니다. 첫 번째 레지스터 세트는 개별 바이트들을 저장하는 데 사용되며, 전체 N 바이트 데이터가 모두 저장되면 폭이 더 넓은 두 번째 레지스터가 업데이트됩니다. 이 두 번째 레지스터가 이중 버퍼이며, 대표적으로 PWM과 같은 다른 모듈을 구동하는 데 사용됩니다.
이중 버퍼 모듈
이중 버퍼 모듈의 블록 다이어그램이 그림 2에 나와 있습니다. 내부적으로 네 개의 주요 부분으로 구성되어 있으며, 그중 가장 중요한 부분은 출력 레지스터입니다. 본 예제에서 출력 레지스터는 16비트 폭으로, 16비트 PWM을 구동하는 데 적합합니다. 해당 출력 레지스터는 본 예제에서 각각 LSB와 MSB로 표시된 개별 8비트 레지스터들에 의해 구동됩니다. 모든 레지스터 업데이트는 이중 버퍼의 제어 부분에 의해 시작된다는 점에 유의해야 합니다. 이 동작은 동기식으로, 모든 구성 요소가 기본 100MHz 클록의 상승 에지에 반응합니다.
그림 2 : 개별 8비트 버퍼와 출력 버퍼 간의 관계를 보여주는 이중 버퍼의 블록 다이어그램.
아래 예제 코드에서 볼 수 있듯이, 각 이중 버퍼 모듈은 특정 주소와 바이트 폭으로 인스턴스화된다는 점을 이해하는 것이 중요합니다. 16비트의 주소, 8비트의 데이터, 그리고 읽기 스트로브가 모두 버퍼를 로딩하는 데 관여한다는 점에 유의해야 합니다. 16비트 주소 입력이 인스턴스화된 주소와 일치할 때 데이터 전송이 시작합니다.
module double_buffer #(
parameter BYTE_WIDTH = 2,
parameter BASE_ADDRESS = 16'h0200 // Starting address for the MSB
) (
input wire clk,
input wire [7:0] data,
input wire [15:0] address,
input wire write_strobe,
output reg [((8 * BYTE_WIDTH) - 1): 0] double_buffer_out,
output reg new_data_strobe
);
그림 1과 2에서 알 수 있듯이, 이 uC에서 FPGA로의 인터페이스는 기본적으로 8비트 전송 과정을 따릅니다. 또한, 2부에서 처음 소개된 명령 프레임에는 연속적인 쓰기 동작이 내포되어 있습니다. 편의를 위해 그림 3에 명령 프레임을 다시 제시하였습니다. 예를 들어, PWM 및 PWM과 관련된 이중 버퍼가 0x0200으로 인스턴스화되었다고 가정해 보겠습니다. 이 경우 명령 프레임의 쓰기 주소는 0x0200으로 설정되며, 페이로드의 첫 두 바이트에 원하는 16비트 PWM 값이 저장되어 있을 것입니다.
그림 3: uC와 FPGA 간 SPI 프로토콜의 기반을 이루는 명령 및 응답 프레임.
명령 프레임이 수신되고 유효성 검증을 통과하면, 메시지 라이터 블록(그림 1 참고)은 PWM의 이중 버퍼를 가리키는 주소 0x0200을 어설트(assert)합니다. 첫 번째 페이로드 바이트를 데이터 버스에 올린 후, 마지막으로 1 클럭 사이클 동안 쓰기 스트로브를 어설트합니다. 이 동작으로 그림 2의 MSB가 로드되며 이는 빅 엔디언 방식입니다.
다음 쓰기 동작을 계속해, 메시지 라이터가 주소를 증가시키고 다음 데이터 바이트를 어설트한 뒤, 쓰기 스트로브 신호를 보내 LSB를 이중 버퍼에 로드합니다. 이 과정은 명령 프레임의 바이트 길이 필드에 따라 명령 프레임 내 모든 바이트에 반복됩니다.
기본적으로 메시지 라이터는 관련된 이중 버퍼의 길이를 인식하지 못합니다. 메시지 라이터는 주소, 데이터 그리고 쓰기 스트로브를 어설트하는 세 단계의 과정에만 관련이 있습니다. 이중 버퍼의 주소가 언제 지정되었고 BYTE_WIDTH 매개변수에 의해 명시된 필요한 바이트 수를 모두 수신하였는지를 인식하는 것은 이중 버퍼 모듈에 달려 있습니다.
이중 버퍼의 기준 주소와 바이트 폭은 인스턴스화 시점에 이미 결정되어 있으므로, 모든 바이트가 수신되었는지를 판단하는 것은 어렵지 않습니다. 본 PWM 예제에서 이중 버퍼는 2까지 카운트한 후, 스트로브 신호를 전송하여 출력 레지스터를 로드합니다.
기술 팁: 데이터는 최상위 바이트(MSB) 또는 최하위 바이트(LSB)부터 접근할 수 있습니다. 이러한 순서를 설명하는 용어가 바로 "엔디언(endian)"입니다. MSB가 먼저 오면 해당 시스템은 빅 엔디언이며, LSB가 먼저 오면 리틀 엔디언입니다. 이 글에서 설명하는 이중 버퍼 및 관련 프레임은 빅 엔디언 방식을 따릅니다.
이중 버퍼 코드
이중 버퍼의 베릴로그 코드가 이 글의 마지막에 첨부되어 있습니다. 이 코드는 그림 2의 블록 다이어그램을 충실히 따르고 있으며, BYTE_WIDTH 매개변수를 변경하여 N 바이트 폭으로 확장할 수 있다는 점을 포함하고 있습니다.
이 코드의 핵심은 베릴로그 제너레이트(generate) 연산자 사용에 있습니다. 제너레이트 기능을 통해 하드웨어는 반복적으로 생성될 수 있으며, 이는 마치 작은 부품을 찍어내는 공장과 같습니다. 단, 이 경우에는 전체 개수가 BYTE_WIDTH 매개변수와 동일한 8비트 레지스터들을 생성하게 됩니다. 그림 4에 제시된 Vivado의 계층 설계 창에서 이 부분을 확인할 수 있으며, 이렇게 “생성된” 블록의 이름은 제너레이트 루프에 정의된 연속적인 명명 방식을 따릅니다.
그림 4: 생성된 1 바이트 크기의 레지스터들은 이중 버퍼 인스턴스 내에서 확인할 수 있습니다.
생성된 각 9비트 레지스터에는 해당하는 local_write_strobe 신호가 포함되어 있음에 주목하십시오. 이 신호는 “제어” 부분이 해당 8비트 레지스터를 로드할 때 사용하기 때문에 중요한 설계 요소입니다.
레지스터 외에도, 제너레이트 루프는 각 8비트 레지스터의 출력을 연결할 8비트 벡터도 생성합니다. 이러한 N개의 8비트 묶음은 하나로 연결(concatenation)된 후 N바이트 출력 레지스터에 전달됩니다.
코드의 마지막 부분은 N바이트가 모두 수집되었는지를 판단합니다. 이후 출력 레지스터를 업데이트하고 new_data_strobe 신호를 전송합니다.
제어 부분은 다음 네 가지 기본 기능을 가지고 있습니다:
- 기준 주소가 인스턴스화된 주소와 일치할 때 모듈을 활성화합니다.
- “생성된” 8비트 레지스터를 가리키는 카운터를 유지합니다. 이 카운터는 연속적인 쓰기에 필수적입니다.
- 해당 8비트 레지스터를 스트로브 합니다.
- N개의 8비트 레지스터가 모두 채워지면 출력 버퍼에 스트로브 신호를 전송합니다.
기술 팁: 벡터는 와이어(wire)들의 1차원 배열입니다. 예를 들어, "input wire [15:0] address"는 address라는 이름의 16비트 벡터를 정의한 것입니다.
4부를 마치며.
이 코드는 확실히 복잡하지만, 베릴로그의 제너레이트 연산자를 통해 유연해 질 수 있습니다. 이를 통해 필요한 바이트 폭마다 별도의 모듈을 따로 구성하지 않아도 됩니다.
다음 편에서는 베릴로그 기반 CRC 생성기의 동작을 살펴보겠습니다. 해당 편이 완료되면, 그림 3에 제시된 프레임이 FPGA에서 어떻게 처리되는지 이해하게 될 것입니다.
여러분의 의견과 제안을 환영합니다. 특히 고급 RTL 시스템 설계 방법론에 대한 추가 논의를 환영합니다.
감사합니다,
APDahlen
//**************************************************************************************************
//
// Module: Parameterized and Addressed Double Buffer
//
// This RTL is subject to Terms and Conditions associated with the
// DigiKey TechForum. Refer to:
// https://www.digikey.com/en/terms-and-conditions?_ga=2.92992980.817147340.1696698685-139567995.1695066003
//
// Should you find an error, please leave a comment in the forum space below.
// If you are able, please provide a recommendation for improvement.
//
//**************************************************************************************************
// ______________________________________________
// | |
// | Module: double_buffer |
// | |
// | Parameters: |
// | BYTE_WIDTH = 4 |
// | BASE_ADDRESS = 16'h0000 |
// |______________________________________________|
// | |
// ==8=| data double_buffer_out |=V==
// ----| write_strobe new_data_strobe |----
// | |
// ----| write_strobe |
// | |
// ----| clk |
// |______________________________________________|
//
//
//** Description ***********************************************************************************
//
// This module works with a stream of parallel data. When the streamed address matches the
// parameterized address the module will store the byte width data into a buffer. The
// module than accepts consecutive data storing each byte in the associated buffer. When the
// module has received BYTE_WIDTH bytes, the data are transferred to the output buffer.
//
//** Instantiation *********************************************************************************
//
// double_buffer #(.BYTE_WIDTH(2), .BASE_ADDRESS(PWM_1_address) )
// PWM_1_driver(
// .clk(clk),
// .data(MSG_writer_data),
// .address(MSG_writer_address),
// .write_strobe(MSG_writer_strobe),
// .double_buffer_out(PWM_1_drive),
// .new_data_strobe(new_data_strobe) // optional
// );
//
//** Signal Inputs: ********************************************************************************
//
// 1) clk: High speed system clock (typically 100 MHz)
//
// 2) data: An 8-bit input. Data will be captured when address is within
// BASE_ADDRESS + BYTE_WIDTH (non-inclusive).
//
// 3) address: A 16-bit input. The module will respond to BYTE_WIDTH consecutive address starting at
// BASE_ADDRESS extending to BASE_ADDRESS + BYTE_WIDTH (non-inclusive).
//
// 4) write strobe: When pulsed, the data will be locked into the associated input buffers provided
// the address is within BASE_ADDRESS + BYTE_WIDTH (non-inclusive).
//
//** Signal Outputs ********************************************************************************
//
// 1) double_buffer_out: This BYTE_WIDTH is the output buffer in this double buffer module. It is
// updated in a single clock cycle after all the individual buffers have been updated. There
// is a one clock delay between the filling the last byte-wide buffer and a double buffer update.
//
// 2) new_data_strobe: a pulse active for the same clock cycle in which the double_buffer is updated.
//
//** Comments **************************************************************************************
//
// 1) TODO, Consider eliminating the one clock cycle delay and eliminating the most significant
// byte input register. Use care for a single byte instantiation.
//
//**************************************************************************************************
module double_buffer #(
parameter BYTE_WIDTH = 4,
parameter BASE_ADDRESS = 16'h0000 // Starting address for the MSB
) (
input wire clk,
input wire [7:0] data,
input wire [15:0] address,
input wire write_strobe,
output reg [((8 * BYTE_WIDTH) - 1): 0] double_buffer_out,
output reg new_data_strobe
);
//** CONSTANT DECLARATIONS *************************************************************************
/* General shortcuts */
localparam T = 1'b1;
localparam F = 1'b0;
//** Body *******************************************************************************************
wire [8*BYTE_WIDTH-1:0] concat_result;
reg delay_one_clk;
wire [7:0] buffer_data_out[BYTE_WIDTH-1:0]; // Array of 8-bit wires
// Generate N 8-bit registers
generate
genvar i;
for (i = 0; i < BYTE_WIDTH; i=i+1) begin: gen_regs
wire local_write_strobe = write_strobe && (address == BASE_ADDRESS + i); // Asserted only when the address matches
local_8bit_reg register_inst (
.clk(clk),
.data(data),
.write_strobe(local_write_strobe),
.q(buffer_data_out[i])
);
end
endgenerate
// Concatenate the outputs of the N 8-bit buffers dynamically
generate
genvar j;
assign concat_result[7:0] = buffer_data_out[0];
for (j = 1; j < BYTE_WIDTH; j=j+1) begin: gen_concat // Note the use of the + operator with the loop starting at 1 not 0
assign concat_result[j*8 +: 8] = buffer_data_out[j];
end
endgenerate
always @(posedge clk) begin // Delay one clock for the double buffer operation to
// allow data to be clocked into the last of the
// individual registers.
new_data_strobe <= F; // Default
delay_one_clk <= F;
if (write_strobe && (address == (BASE_ADDRESS + BYTE_WIDTH - 1))) begin
delay_one_clk <= T;
end
if (delay_one_clk)begin
double_buffer_out <= concat_result;
new_data_strobe <= T;
end
end
endmodule
module local_8bit_reg (
input wire clk,
input wire [7:0] data,
input wire write_strobe,
output reg [7:0] q
);
always @(posedge clk) begin
if (write_strobe) begin
q <= data;
end
end
endmodule
영문 원본: Implementing a Robust Microcontroller to FPGA SPI Interface: Part 4 - Double Buffer