1. 高精度数字秒表的设计思路

做FPGA开发这么多年,我发现在数字逻辑设计中最有意思的就是计时器项目。高精度数字秒表看似简单,但里面藏着不少精妙的设计思路。这次我们要做的秒表需要达到毫秒级精度,最长计时1小时,还得有复位和起停控制功能。

先说说整体架构。我们的秒表核心由几个关键模块组成:分频器负责将外部时钟信号分频到我们需要的工作频率;多级计数器链用来处理不同时间单位(毫秒、秒、分);还有控制逻辑模块管理复位和起停功能。这种模块化设计让整个系统更加清晰,也便于调试和维护。

我习惯从需求倒推设计。我们需要120kHz的外部时钟输入,计时精度要达到1毫秒,这意味着我们需要一个1kHz的计时脉冲。怎么实现呢?很简单,用分频器将120kHz分频到1kHz就行了。然后就是计数器链的设计:三个十进制计数器处理毫秒位(0-999),两个计数器处理秒位(0-59),还有两个处理分钟位(0-59)。

在实际项目中,我最看重的是控制逻辑的设计。起停开关要像传统机械秒表那样工作 - 按一下开始计时,再按一下暂停。复位开关则要能够在任何状态下立即清零计时器。这些功能需要通过巧妙的状态机来实现,确保控制信号的同步和稳定性。

2. 分频器模块详解

分频器是整个系统的时钟心脏。我们有个120kHz的输入时钟,但需要的是1kHz的工作时钟,所以得分频120倍。听起来很简单,不就是计数到120然后产生一个脉冲吗?但实际实现时有很多细节要注意。

让我分享一个实用的分频器设计。我们使用一个计数器,从0计数到119(因为0到119就是120个时钟周期),每次计数到119时输出一个脉冲,同时计数器归零。这样就能得到精确的1kHz信号。在VHDL中,我们可以这样实现:

ENTITY clk_divider IS
    PORT (
        clk_in : IN STD_LOGIC;
        clk_out : OUT STD_LOGIC
    );
END clk_divider;

ARCHITECTURE behavioral OF clk_divider IS
    SIGNAL counter : INTEGER RANGE 0 TO 119 := 0;
    SIGNAL temp_clk : STD_LOGIC := '0';
BEGIN
    PROCESS (clk_in)
    BEGIN
        IF rising_edge(clk_in) THEN
            IF counter = 119 THEN
                counter <= 0;
                temp_clk <= NOT temp_clk;
            ELSE
                counter <= counter + 1;
            END IF;
        END IF;
    END PROCESS;
    
    clk_out <= temp_clk;
END behavioral;

这个设计的好处是占空比接近50%,而且不会出现毛刺。在实际测试中,这种分频方式非常稳定,不会因为计数器的溢出或其他问题导致时钟信号异常。

提示:分频器设计时要特别注意复位信号的处理,确保系统上电或复位时计数器处于已知状态

仿真的时候要重点关注分频后的时钟频率是否准确。我在Quartus中经常用的技巧是测量多个周期的时间来计算平均频率,这样可以避免测量误差。120kHz到1kHz的分频,意味着每个输出时钟周期应该正好是1毫秒,这个一定要验证准确。

3. 十进制计数器设计与实现

十进制计数器是秒表的核心计数单元,我一般会设计成带使能和进位输出的通用模块。这样不仅秒表能用,其他需要计数器的项目也能复用。

先看看接口设计。除了必需的时钟和清零信号外,我增加了使能信号(ena)来控制计数器的启停,还有进位输出(carry_out)用于级联到下一个计数器。这样的设计非常灵活,可以用在计数链的任何位置。

在实际编码中,我推荐使用std_logic_unsigned包,这样可以直接对std_logic_vector进行算术运算,代码会更简洁。计数器的核心逻辑其实很简单:当时钟上升沿到来时,如果清零信号有效就归零,否则如果使能信号有效就加一计数。

LIBRARY ieee;
USE ieee.std_logic_1164.all;
USE ieee.std_logic_unsigned.all;

ENTITY count_10 IS
    PORT (
        clk : IN STD_LOGIC;
        clr : IN STD_LOGIC;
        ena : IN STD_LOGIC;
        CQ : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);
        carry_out : OUT STD_LOGIC
    );
END count_10;

ARCHITECTURE behavioral OF count_10 IS
    SIGNAL count : STD_LOGIC_VECTOR(3 DOWNTO 0) := "0000";
BEGIN
    PROCESS (clk, clr)
    BEGIN
        IF clr = '1' THEN
            count <= "0000";
        ELSIF rising_edge(clk) THEN
            IF ena = '1' THEN
                IF count = "1001" THEN  -- 十进制9
                    count <= "0000";
                ELSE
                    count <= count + 1;
                END IF;
            END IF;
        END IF;
    END PROCESS;
    
    CQ <= count;
    carry_out <= '1' WHEN count = "1001" AND ena = '1' ELSE '0';
END behavioral;

这个设计有几个值得注意的细节。首先,清零是异步的,这样任何时候都可以立即清零,符合秒表的复位需求。其次,进位输出只在计数到9且使能有效时才产生,避免了不必要的进位信号。

在仿真测试时,要特别验证几个边界情况:计数从9翻转到0时进位信号是否正确,使能信号无效时是否真的停止计数,清零信号是否立即生效。这些都是容易出问题的地方。

4. 六进制计数器的特殊处理

六进制计数器在设计上和十进制计数器类似,但只需要计数到5。这在处理秒和分的十位数时特别有用,因为时间显示是60进制的,个位是十进制,十位是六进制。

我通常会在六进制计数器中加入更多的错误检查机制。因为计数到6以上就是异常状态,需要在设计时就避免这种情况。一种做法是在计数到5时自动归零,并产生进位信号。

ENTITY count_6 IS
    PORT (
        clk : IN STD_LOGIC;
        clr : IN STD_LOGIC;
        ena : IN STD_LOGIC;
        CQ : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);
        carry_out : OUT STD_LOGIC
    );
END count_6;

ARCHITECTURE behavioral OF count_6 IS
    SIGNAL count : STD_LOGIC_VECTOR(3 DOWNTO 0) := "0000";
BEGIN
    PROCESS (clk, clr)
    BEGIN
        IF clr = '1' THEN
            count <= "0000";
        ELSIF rising_edge(clk) THEN
            IF ena = '1' THEN
                IF count = "0101" THEN  -- 六进制5
                    count <= "0000";
                ELSE
                    count <= count + 1;
                END IF;
            END IF;
        END IF;
    END PROCESS;
    
    CQ <= count;
    carry_out <= '1' WHEN count = "0101" AND ena = '1' ELSE '0';
END behavioral;

虽然六进制只需要计数到5,但我还是用了4位输出,这样和其他十进制计数器保持接口一致,便于顶层连接。carry_out信号在计数到5时产生,用于触发下一级计数。

在实际项目中,我发现六进制计数器最容易出的问题是计数序列错误。一定要确保不会计数到6、7、8、9这些非法状态。好的设计应该在仿真阶段就发现这些问题。

5. 顶层模块设计与系统集成

顶层模块就像乐高积木的底座,把所有计数器模块有机地组合起来。这里最重要的是理解各级计数器之间的时序关系和数据流向。

我的连接策略是这样的:分频器产生的1kHz时钟驱动第一个毫秒计数器(0-9),这个计数器的进位输出驱动第二个毫秒计数器(0-9),第二个的进位再驱动第三个毫秒计数器(0-9)。第三个毫秒计数器的进位驱动秒个位计数器,依此类推,形成完整的计数链。

ENTITY stopwatch_top IS
    PORT (
        clk_120k : IN STD_LOGIC;      -- 120kHz输入时钟
        reset : IN STD_LOGIC;          -- 复位信号
        start_stop : IN STD_LOGIC;     -- 起停信号
        ms0, ms1, ms2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);  -- 毫秒输出
        sec0, sec1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);     -- 秒输出
        min0, min1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0)      -- 分输出
    );
END stopwatch_top;

ARCHITECTURE structural OF stopwatch_top IS
    -- 内部信号声明
    SIGNAL clk_1k : STD_LOGIC;
    SIGNAL carry_ms0, carry_ms1, carry_ms2 : STD_LOGIC;
    SIGNAL carry_sec0, carry_sec1 : STD_LOGIC;
    SIGNAL carry_min0 : STD_LOGIC;
    SIGNAL enable : STD_LOGIC;
    SIGNAL start_stop_n : STD_LOGIC;
    
    -- 组件声明
    COMPONENT clk_divider
        PORT (clk_in : IN STD_LOGIC; clk_out : OUT STD_LOGIC);
    END COMPONENT;
    
    COMPONENT count_10
        PORT (clk, clr, ena : IN STD_LOGIC; 
              CQ : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); 
              carry_out : OUT STD_LOGIC);
    END COMPONENT;
    
    COMPONENT count_6
        PORT (clk, clr, ena : IN STD_LOGIC;
              CQ : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);
              carry_out : OUT STD_LOGIC);
    END COMPONENT;

BEGIN
    -- 分频器实例化
    div: clk_divider PORT MAP (clk_in => clk_120k, clk_out => clk_1k);
    
    -- 起停控制逻辑
    PROCESS (start_stop, reset)
    BEGIN
        IF reset = '1' THEN
            enable <= '0';
            start_stop_n <= '0';
        ELSIF rising_edge(start_stop) THEN
            enable <= NOT enable;
        END IF;
    END PROCESS;
    
    -- 毫秒计数器链
    ms_counter0: count_10 PORT MAP (
        clk => clk_1k, clr => reset, ena => enable,
        CQ => ms0, carry_out => carry_ms0
    );
    
    ms_counter1: count_10 PORT MAP (
        clk => carry_ms0, clr => reset, ena => enable,
        CQ => ms1, carry_out => carry_ms1
    );
    
    ms_counter2: count_10 PORT MAP (
        clk => carry_ms1, clr => reset, ena => enable,
        CQ => ms2, carry_out => carry_ms2
    );
    
    -- 秒计数器链
    sec_counter0: count_10 PORT MAP (
        clk => carry_ms2, clr => reset, ena => enable,
        CQ => sec0, carry_out => carry_sec0
    );
    
    sec_counter1: count_6 PORT MAP (
        clk => carry_sec0, clr => reset, ena => enable,
        CQ => sec1, carry_out => carry_sec1
    );
    
    -- 分钟计数器链
    min_counter0: count_10 PORT MAP (
        clk => carry_sec1, clr => reset, ena => enable,
        CQ => min0, carry_out => carry_min0
    );
    
    min_counter1: count_6 PORT MAP (
        clk => carry_min0, clr => reset, ena => enable,
        CQ => min1, carry_out => OPEN
    );
END structural;

这个顶层设计中最巧妙的是起停控制逻辑。我用了一个简单的触发器来切换使能信号的状态,这样每次按下起停开关,计时状态就会翻转。复位信号则直接连接到所有计数器的清零端,确保任何情况下都能立即清零。

6. Quartus仿真技巧与实战

仿真可是FPGA设计中的重头戏,好的仿真能发现90%以上的设计错误。在Quartus中做仿真,我总结了一套高效的工作流程。

首先建立测试平台。我喜欢用VHDL Test Bench来写仿真代码,这样更灵活。测试平台要覆盖所有关键场景:正常计时、暂停、继续、复位等。特别是边界条件的测试,比如计数到59分59.999秒后是否能够正确归零。

LIBRARY ieee;
USE ieee.std_logic_1164.all;

ENTITY tb_stopwatch IS
END tb_stopwatch;

ARCHITECTURE testbench OF tb_stopwatch IS
    COMPONENT stopwatch_top
        PORT (
            clk_120k : IN STD_LOGIC;
            reset : IN STD_LOGIC;
            start_stop : IN STD_LOGIC;
            ms0, ms1, ms2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);
            sec0, sec1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);
            min0, min1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0)
        );
    END COMPONENT;
    
    SIGNAL clk_120k : STD_LOGIC := '0';
    SIGNAL reset : STD_LOGIC := '1';
    SIGNAL start_stop : STD_LOGIC := '0';
    SIGNAL ms0, ms1, ms2 : STD_LOGIC_VECTOR(3 DOWNTO 0);
    SIGNAL sec0, sec1 : STD_LOGIC_VECTOR(3 DOWNTO 0);
    SIGNAL min0, min1 : STD_LOGIC_VECTOR(3 DOWNTO 0);
    
    CONSTANT clk_period : TIME := 8.333 us;  -- 120kHz周期

BEGIN
    uut: stopwatch_top PORT MAP (
        clk_120k => clk_120k,
        reset => reset,
        start_stop => start_stop,
        ms0 => ms0, ms1 => ms1, ms2 => ms2,
        sec0 => sec0, sec1 => sec1,
        min0 => min0, min1 => min1
    );
    
    -- 时钟生成
    clk_process: PROCESS
    BEGIN
        clk_120k <= '0';
        WAIT FOR clk_period/2;
        clk_120k <= '1';
        WAIT FOR clk_period/2;
    END PROCESS;
    
    -- 测试序列
    stim_proc: PROCESS
    BEGIN
        -- 初始复位
        reset <= '1';
        WAIT FOR 100 ns;
        reset <= '0';
        
        -- 开始计时
        WAIT FOR clk_period * 10;
        start_stop <= '1';
        WAIT FOR clk_period;
        start_stop <= '0';
        
        -- 计时一段时间
        WAIT FOR 50 ms;
        
        -- 暂停
        start_stop <= '1';
        WAIT FOR clk_period;
        start_stop <= '0';
        
        -- 再继续
        WAIT FOR 10 ms;
        start_stop <= '1';
        WAIT FOR clk_period;
        start_stop <= '0';
        
        -- 计时到边界值测试
        WAIT FOR 100 ms;
        
        -- 强制复位
        reset <= '1';
        WAIT FOR clk_period * 2;
        reset <= '0';
        
        WAIT;
    END PROCESS;
END testbench;

在仿真波形分析时,我习惯分层查看信号。先看全局时钟和复位,再看各级计数器之间的进位关系。特别要注意进位信号的时序,确保不会出现竞争冒险现象。有时候需要放大波形仔细查看时钟边沿和信号变化的关系。

注意:仿真时设置合理的时间精度很重要,对于毫秒级精度的设计,时间精度至少要到1ns才能看到细节

另一个实用技巧是使用Quartus的Signal Tap Logic Analyzer进行在线调试。虽然仿

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐