3、SPI时钟发生器设计:波特率分频原理、占空比调整、时钟使能信号生成、跨时钟域同步处理
SPI通信的速度,说白了就是由时钟发生器决定的。你想想看,主设备发一个SCK脉冲,从设备就采样一个bit。这个脉冲多快、多稳,直接决定了整个系统能不能正常工作。我刚开始做SPI控制器时,觉得时钟分频不就是个计数器嘛,有什么难的?结果第一次联调就翻车了——从设备死活采不到正确数据。后来才发现,是时钟使能信号的相位没对齐。
这一节,我们就来把SPI时钟发生器的每个细节掰开揉碎。我会结合自己踩过的坑,带你一步步搞定波特率分频、占空比调整、时钟使能生成,还有跨时钟域同步这些关键点。
3.1 波特率分频原理
SPI的时钟频率,通常由系统时钟分频得到。比如系统时钟是50MHz,你想让SPI跑在10MHz,那就分频5倍。但这里有个细节:分频系数是整数还是半整数?
我个人习惯把分频器设计成支持任意整数分频。为什么?因为SPI从设备对时钟频率的要求千奇百怪。有的支持到20MHz,有的只能跑1MHz。你设计一个固定分频比的模块,换个场景就得重新改代码,太不灵活了。
来看一个典型的偶数分频实现:
module spi_clk_divider #(
parameter DIV_RATIO = 4 // 分频系数,必须是偶数
)(
input wire clk_i,
input wire rst_n_i,
output reg sck_o
);
reg [$clog2(DIV_RATIO)-1:0] cnt;
always @(posedge clk_i or negedge rst_n_i) begin
if (!rst_n_i) begin
cnt <= 0;
sck_o <= 1'b0;
end else begin
if (cnt == (DIV_RATIO/2 - 1)) begin
cnt <= 0;
sck_o <= ~sck_o;
end else begin
cnt <= cnt + 1'b1;
end
end
end
endmodule
这个代码很简单:计数器从0数到(DIV_RATIO/2 - 1),然后翻转SCK。这样SCK的周期就是系统时钟周期的DIV_RATIO倍。占空比正好50%。
但如果你需要奇数分频呢?比如分频3倍?嗯,这里要注意:奇数分频的占空比不是50%,除非你用双边沿触发。我一般不建议在SPI时钟上用奇数分频,因为从设备通常期望50%占空比的时钟。实在要用,也得保证高电平时间与低电平时间相差不超过一个系统时钟周期。
关键点:分频系数最好设计成可配置的,通过寄存器写入。这样同一个硬件模块,可以适配不同速度的从设备。
3.2 占空比调整
为什么需要调整占空比?我在项目中遇到过这样一个场景:某个从设备对SCK的上升沿采样,但它的内部逻辑有延迟,导致在标准50%占空比下,数据建立时间不够。解决办法就是调整SCK的占空比,让高电平时间变长一点,给数据多留点时间稳定。
占空比调整的核心思路:把分频计数器拆成两个阈值——一个控制高电平宽度,一个控制低电平宽度。两个阈值之和等于分频系数。
module spi_clk_duty #(
parameter DIV_CNT = 8, // 分频系数
parameter HIGH_CNT = 3 // 高电平保持的时钟周期数
)(
input wire clk_i,
input wire rst_n_i,
output reg sck_o
);
reg [$clog2(DIV_CNT)-1:0] cnt;
always @(posedge clk_i or negedge rst_n_i) begin
if (!rst_n_i) begin
cnt <= 0;
sck_o <= 1'b0;
end else begin
if (cnt == (DIV_CNT - 1)) begin
cnt <= 0;
sck_o <= 1'b1; // 新周期开始,拉高
end else if (cnt == (HIGH_CNT - 1)) begin
cnt <= cnt + 1'b1;
sck_o <= 1'b0; // 高电平结束,拉低
end else begin
cnt <= cnt + 1'b1;
end
end
end
endmodule
这里HIGH_CNT就是高电平持续的时钟周期数。比如DIV_CNT=8,HIGH_CNT=3,那么占空比就是3/8=37.5%。你可以通过改变HIGH_CNT的值,灵活调整占空比。
警告:占空比调整要小心!有些从设备对最小高电平时间有要求。比如某些Flash芯片要求SCK高电平时间不低于10ns。如果你把占空比调得太小,高电平时间不够,从设备可能无法正确识别时钟边沿。
3.3 时钟使能信号生成
时钟使能信号,说白了就是一个脉冲信号,用来告诉其他模块「现在可以采样/发送数据了」。它比直接用分频后的SCK更安全,因为你可以控制使能信号与系统时钟的相位关系。
我一般这样生成时钟使能:在分频计数器的每个周期结束时,产生一个单周期脉冲。
module spi_clk_en #(
parameter DIV_RATIO = 4
)(
input wire clk_i,
input wire rst_n_i,
output reg sck_en_o,
output reg sck_o
);
reg [$clog2(DIV_RATIO)-1:0] cnt;
always @(posedge clk_i or negedge rst_n_i) begin
if (!rst_n_i) begin
cnt <= 0;
sck_o <= 1'b0;
sck_en_o <= 1'b0;
end else begin
sck_en_o <= 1'b0; // 默认拉低
if (cnt == (DIV_RATIO/2 - 1)) begin
cnt <= 0;
sck_o <= ~sck_o;
sck_en_o <= 1'b1; // 翻转时产生使能脉冲
end else begin
cnt <= cnt + 1'b1;
end
end
end
endmodule
这个使能信号sck_en_o在每个SCK边沿(上升沿和下降沿)都会产生一个脉冲。你可以用它来触发数据移位寄存器的操作。这样做的好处是:数据操作始终与系统时钟同步,避免了直接使用SCK带来的跨时钟域问题。
技巧:如果你只需要在SCK的上升沿采样数据,那就只在计数器翻转到高电平时产生使能脉冲。这样数据采样点正好在SCK的中间位置,建立时间和保持时间都最充裕。
3.4 跨时钟域同步处理
这是最容易出问题的地方。SPI控制器通常工作在系统时钟域,但SCK是分频产生的,频率可能比系统时钟低很多。如果你直接把SCK信号送到其他时钟域(比如APB总线时钟域),就会产生亚稳态风险。
我曾经在一个项目中,直接把SCK使能信号送到了数据接收模块。结果偶尔会出现数据错位——一个bit变成了两个,或者漏掉了一个bit。查了三天,最后发现是跨时钟域没做同步。
标准的做法是:用两级触发器做同步。对于单bit信号,这是最可靠的方案。
module sync_pulse #(
parameter STAGES = 2
)(
input wire clk_dst_i,
input wire rst_n_i,
input wire pulse_src_i,
output wire pulse_dst_o
);
reg [STAGES-1:0] sync_reg;
always @(posedge clk_dst_i or negedge rst_n_i) begin
if (!rst_n_i) begin
sync_reg <= {STAGES{1'b0}};
end else begin
sync_reg <= {sync_reg[STAGES-2:0], pulse_src_i};
end
end
assign pulse_dst_o = sync_reg[STAGES-1];
endmodule
但这里有个坑:如果源时钟域的脉冲宽度小于目的时钟域的一个周期,两级同步可能会漏掉这个脉冲。怎么办?我建议把脉冲展宽,确保它至少保持两个目的时钟周期。
对于SPI时钟使能信号,因为它是系统时钟域产生的,所以通常不需要做跨时钟域同步——它本身就和系统时钟同源。但如果你要把SCK信号送到外部接口或者另一个时钟域,那就必须做同步。
总结一下我的经验:
- 时钟使能信号:与系统时钟同域,不需要同步
- SCK输出信号:如果直接驱动外部引脚,不需要同步(但要注意IO时序)
- SCK反馈信号:如果内部逻辑需要检测SCK边沿,建议用系统时钟采样并做边沿检测
- 跨时钟域数据:用异步FIFO或者握手协议,不要直接用两级同步处理多bit数据
3.5 整体结构图
下面这张图展示了SPI时钟发生器的整体结构。你可以看到,波特率分频、占空比调整、时钟使能生成是串联的,而跨时钟域同步是独立的一层,用于保护数据通路。
从图中你可以看到,整个时钟发生器是一个流水线结构。系统时钟先经过分频器,再调整占空比,最后生成使能脉冲。跨时钟域同步模块则独立出来,专门处理需要跨越时钟域的信号。
嗯,到这里SPI时钟发生器的核心内容就讲完了。我个人觉得,这部分设计的关键不在于代码有多复杂,而在于你要想清楚每个信号的作用域和时序关系。尤其是跨时钟域同步,很多人觉得不就是加两级触发器嘛,但实际项目中因为同步不当导致的问题,我见过太多了。
记住一句话:时钟是数字系统的脉搏。脉搏乱了,整个系统都会出问题。