Verilog实现硬件轮询调度器:从算法原理到RTL设计
2026/8/25 10:12:47 网站建设 项目流程

1. 项目概述:当硬件逻辑遇上“公平”的艺术

在芯片前端设计的江湖里,数据流的调度是个永恒的话题。当多个请求源(比如几个处理器核心、几个DMA控制器或者几个外设)同时嗷嗷待哺,都想访问同一个共享资源(比如一块内存、一个总线或者一个计算单元)时,你该怎么办?是让某个“关系户”一直霸占着,还是搞个先来后到的排队?这两种简单粗暴的方式在追求高性能和低延迟的硬件世界里,往往都会带来问题。前者可能导致“饿死”——某些请求永远得不到响应;后者在请求频率不均时,又显得不够灵活。这时候,轮询调度,也就是我们常说的RR调度,就成了一种非常经典且实用的折中方案。

RR调度的核心思想就两个字:公平。它像一个耐心的裁判,让所有等待的请求者排成一个圆圈,然后按顺序、一个接一个地提供服务,每个请求者服务一次(或一个固定的时间片)后就轮到下一个,如此循环往复。这种机制保证了在足够长的时间窗口内,每个请求者都能获得大致相等的服务机会,避免了单一请求源的垄断。在芯片内部,从总线仲裁、缓存替换策略到多核任务调度,RR的身影无处不在。

这次,我们不谈软件算法,而是深入到硅片之上,用Verilog这门硬件描述语言,亲手打造一个真正的、能在FPGA或ASIC中运行的RR调度器。这不仅仅是写几行代码,更是理解硬件如何以并行的、时钟驱动的思维方式,去实现一个在软件看来很“顺序”的算法。你会发现,用Verilog实现RR,就像用乐高积木搭建一个精密的机械钟表,每一个触发器、每一根连线,都需要你精心设计,以确保在每一个时钟沿,整个系统都能准确无误地运转。接下来,我们就从最核心的需求开始,拆解这个硬件调度器的设计与实现。

1.1 核心需求解析:硬件调度器的独特挑战

在软件中实现一个RR调度器,你可能用一个链表或数组,配合一个指针就能轻松搞定。但在硬件世界里,一切都变了。硬件设计首要考虑的是并行性、时序和面积。我们的RR调度器需要满足以下几个硬核需求:

  1. 确定性延迟:从输入请求有效,到输出授权有效,这个延迟必须是确定且尽可能短的。软件调度可能因为操作系统中断而波动,但硬件调度器必须在几个时钟周期内给出结果,这对系统整体性能至关重要。
  2. 高吞吐量:理想情况下,每个时钟周期都能处理一次调度决策。这意味着我们的设计必须是流水线化或组合逻辑优化的,不能有复杂的循环依赖。
  3. 低硬件开销:在芯片上,每一个触发器(FF)和查找表(LUT)都是宝贵的资源。我们的设计需要在满足功能的前提下,尽可能节省面积。
  4. 处理动态请求:请求源可能在任何时刻拉高或拉低其请求信号。调度器必须能实时响应这些变化,在下一轮调度中立刻排除不再请求的源,或者加入新出现的请求源。
  5. 避免优先级反转:一个基础的RR应该是公平的,但有时我们可能需要引入“权重”或“优先级”的概念,这属于增强型RR。我们首先实现最基础的公平轮询。

基于这些需求,一个典型的硬件RR调度器接口可以这样定义:

  • clkrst_n: 全局时钟和低有效复位。
  • req[N-1:0]: N位宽的输入请求向量,req[i]为1表示第i个请求源当前有请求。
  • grant[N-1:0]: N位宽的输出授权向量,grant[i]为1表示本轮调度授权给第i个请求源。通常采用独热码,即同一时刻只有一位为1。
  • grant_valid: 输出有效信号,当有任意请求且调度完成时拉高。

我们的目标就是设计一个模块,在每一个时钟周期,根据当前的req和内部维护的“上一次服务指针”,计算出本次应该授权的grant信号。

1.2 方案选型:从朴素算法到高效硬件映射

实现RR的硬件算法有很多,选择哪种取决于我们对性能、面积和代码可读性的权衡。

方案一:优先级编码器 + 指针循环这是最直观的思路。我们可以维护一个指针pointer,指向下一个具有最高优先级的请求源(假设指针所指的源优先级最高,然后序号递增的源优先级依次降低)。每一轮调度,我们以pointer所指位置为最高优先级,对req进行一个“循环优先级编码”。这可以通过将req向量循环左移pointer位,然后进行普通的优先级编码(如找最低位为1的位),再将编码结果加上pointer并取模N来实现。这种方案逻辑清晰,但需要循环移位和模运算,在硬件上可能不是最省面积的。

方案二:掩码比较法这是一种更经典、更高效的硬件实现方法,也是我们本次重点实现的方案。其核心思想是:

  1. 维护一个上一次被授权的源索引last_grant_id(或者一个独热码形式的last_grant向量)。
  2. req向量根据last_grant_id分成两部分:比last_grant_id序号高的部分,和比它低的部分。
  3. 在硬件上,这可以通过生成两个掩码来实现:一个“高位掩码”用于屏蔽掉last_grant_id及其之前的位,只保留序号更高的请求;一个“低位掩码”用于屏蔽掉last_grant_id之后的位,只保留序号更低的请求。
  4. 调度策略是:先检查高位部分是否有请求,有则授权给其中序号最小的;如果高位部分全无请求,则检查低位部分,授权给其中序号最小的。这完美模拟了“从上一次服务点的下一个位置开始,循环查找第一个请求”的RR行为。
  5. 找到新的授权源后,更新last_grant_id

这种方法将循环查找转化为了并行的掩码生成和优先级编码,非常契合硬件并行处理的特性,关键路径短,易于达到高频率。我们将采用这种方案进行实现。

注意:在真正的工业级设计中,尤其是请求源数量N较大(比如64)时,可能会采用“矩阵仲裁”或“并行前缀”等更复杂的结构来优化关键路径。但对于大多数中小规模(N<=16)的应用,掩码比较法在性能、面积和复杂度上取得了很好的平衡。

2. 核心模块设计与接口定义

明确了算法,我们就可以开始动手画框图、写代码了。一个严谨的设计总是从接口开始。

2.1 模块接口与参数化设计

我们希望设计一个高度参数化的调度器,可以灵活配置请求源的数量。这通过Verilog的parameter来实现。

module rr_arbiter #( parameter N = 4 // 请求源数量,默认为4 )( input wire clk, input wire rst_n, // 低电平有效异步复位 input wire [N-1:0] req, // 输入请求,每位代表一个请求源 output reg [N-1:0] grant, // 输出授权,独热码 output wire grant_valid // 授权有效信号 );

接口信号详解

  • clkrst_n: 标准全局信号。采用异步复位、同步释放的策略是常见且可靠的选择。
  • req[N-1:0]: 输入请求。这是一个向量,req[i]为1表示第i号请求源在当前周期有请求。它是电平敏感的,只要为高,就表示该源持续请求。
  • grant[N-1:0]: 输出授权。这是一个独热码输出,同一时刻只能有一位为1(除非无请求,则全0)。grant[i]为1表示仲裁器决定将资源授权给第i号请求源。使用独热码方便下游逻辑直接使用,无需解码。
  • grant_valid: 这是一个组合逻辑输出。当req不为零且仲裁逻辑完成计算后,该信号拉高,指示grant输出有效。这个信号对于连接那些需要“握手”协议的下游模块非常有用。

参数化设计的好处: 通过parameter N,我们可以用同一份RTL代码实例化出仲裁任意数量请求源的RR调度器,极大地提高了代码的复用性。在顶层集成时,只需要根据实际需求修改N的值即可。

2.2 内部状态与关键寄存器

为了实现“轮询”,模块内部必须记忆“上一次把权限给了谁”。这是调度器唯一需要记忆的状态。

// 内部寄存器声明 reg [N-1:0] last_grant; // 上一次的授权结果,独热码格式 // 或者另一种存储方式: // reg [$clog2(N)-1:0] last_grant_id; // 上一次授权的源索引(二进制)

两种存储方式各有优劣:

  • 独热码存储 (last_grant): 优点是生成掩码时非常方便,可以直接用于位操作。缺点是消耗的触发器数量为N个,当N很大时面积开销较大。
  • 索引存储 (last_grant_id): 优点是存储面积小,只需要$clog2(N)个触发器。缺点是在生成掩码时需要将索引解码成独热码,或者进行更复杂的算术比较,增加了一些组合逻辑。

对于中小规模N(比如<=8),独热码存储因其逻辑简单、时序性好而被广泛采用。我们这里选择独热码存储方式。last_grant在复位时会被清零,表示初始状态下,没有任何源被服务过,调度将从0号源开始查找。

3. 仲裁逻辑的硬件实现详解

这是整个设计的核心,我们将“掩码比较法”用Verilog描述出来。整个仲裁逻辑可以分为组合逻辑部分和时序逻辑部分。

3.1 组合逻辑部分:实时计算授权

组合逻辑负责在每个时钟周期,根据当前的req和寄存的last_grant,计算出本次应该的grantgrant_valid。注意,这部分代码是always @*块(或assign语句),不依赖于时钟。

第一步:生成掩码我们需要生成两个掩码:upper_masklower_mask

  • upper_mask: 用于筛选出序号比last_grant所指源更高的请求。假设last_grant是独热码0001(表示上次服务了源0),那么upper_mask应该是1110(屏蔽掉第0位)。
  • lower_mask: 用于筛选出序号比last_grant所指源更低的请求。接上例,lower_mask应该是0000(因为0是最低序号,没有更低的源了)。如果last_grant0010(源1),则lower_mask0001

在Verilog中,可以利用位拼接和循环左移/右移来优雅地实现。一种常见技巧是:

// 假设 last_grant 是独热码 wire [N-1:0] upper_mask = {last_grant[N-2:0], 1‘b0}; // 将last_grant左移一位,最低位补0 wire [N-1:0] lower_mask = ~(upper_mask | last_grant); // 既不是last_grant也不是upper_mask的部分

但更通用、更清晰的方法是使用双倍的位宽来避免边界条件判断:

wire [2*N-1:0] double_req = {req, req}; // 将req复制一份拼接起来 wire [2*N-1:0] double_mask = ({{N{1‘b1}}, {N{1‘b0}}} << (last_grant_id + 1)); // last_grant_id需要从独热码解码得到 wire [N-1:0] upper_req = double_req[last_grant_id +: N] & double_mask[last_grant_id +: N]; // 然后对 upper_req 进行优先级编码(找最低位1)

这种方法逻辑正确但略显复杂。为了首次实现更直观,我们采用一种基于循环的查找描述,再让综合器去优化。实际上,对于综合工具,我们可以直接描述算法行为:

// 这是一个描述性的行为级代码,综合工具会将其映射为相应的硬件电路 always @* begin grant = {N{1‘b0}}; // 默认授权为0 grant_valid = 1‘b0; if (req != 0) begin // 如果有任何请求 integer i, start; // 将 last_grant 的独热码转换为索引,用于查找起点。这里用一个for循环模拟。 start = 0; for (i = 0; i < N; i = i + 1) begin if (last_grant[i]) start = (i + 1) % N; // 从下一个位置开始 end // 从start开始,循环查找第一个请求 for (int j = 0; j < N; j = j + 1) begin int idx = (start + j) % N; if (req[idx]) begin grant[idx] = 1‘b1; grant_valid = 1‘b1; break; // 找到第一个就退出 end end end end

注意:上面的代码是高度可读的行为级描述,它清晰地表达了RR算法“从上一次的下一个开始循环找第一个”的语义。现代综合工具(如Synopsys DC, Cadence Genus)能够很好地识别这种模式,并将其综合成与“掩码比较法”性能相近的硬件电路,可能是多路选择器链或并行比较树。对于初学者,理解算法本质比一开始就纠结于最优门级实现更重要。我们可以先以此为基础实现功能。

3.2 时序逻辑部分:更新历史状态

当时钟上升沿到来时,我们需要用本次计算出的grant来更新last_grant寄存器,为下一个周期的仲裁做准备。

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin last_grant <= {N{1‘b0}}; // 复位时,没有历史授权 // 或者可以初始化为 last_grant <= 1; // 让第一次调度从源0开始查找,这取决于你的“循环”定义 end else begin if (grant_valid) begin last_grant <= grant; // 只有当本次有有效授权时,才更新历史记录 end // 如果没有有效授权(req全为0),则last_grant保持不变 end end

关键点last_grant只在grant_valid有效时才更新。如果当前周期没有请求(req全0),则grant为0,grant_valid为0,last_grant保持原值。这确保了调度器在请求间歇期能记住上一次服务的位置。

3.3 一个完整的Verilog实现示例

将组合逻辑和时序逻辑结合起来,并优化一下行为级描述,我们得到一个完整且可综合的模块:

module rr_arbiter #( parameter N = 4 )( input wire clk, input wire rst_n, input wire [N-1:0] req, output reg [N-1:0] grant, output wire grant_valid ); reg [N-1:0] last_grant; // 组合逻辑:计算本次授权 always @* begin grant = {N{1‘b0}}; grant_valid = 1‘b0; if (|req) begin // 简化写法,等价于 req != 0 // 查找 last_grant 中为1的位,确定起点 integer start_idx; start_idx = 0; // 默认值,防止锁存器 for (integer k = 0; k < N; k = k + 1) begin if (last_grant[k]) begin start_idx = (k + 1) % N; end end // 从start_idx开始循环查找 for (integer j = 0; j < N; j = j + 1) begin integer idx; idx = (start_idx + j) % N; if (req[idx]) begin grant[idx] = 1‘b1; grant_valid = 1‘b1; disable find_grant; // 使用disable退出命名块,模拟break end end find_grant: ; // 命名块标签 end end // 时序逻辑:更新历史授权记录 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin last_grant <= {N{1‘b0}}; end else begin if (grant_valid) begin last_grant <= grant; end end end endmodule

实操心得:代码中的disable find_grant;是一种行为级建模技巧,用于跳出循环。在可综合代码中,综合工具会理解这个意图并将其转化为适当的硬件结构(如优先级编码逻辑)。你也可以使用break语句(SystemVerilog支持),但确保你的工具链支持。另一种更“硬件化”的写法是使用generate块和多个assign语句构建一个多级选择器,但对于中等大小的N,行为级描述更简洁且综合结果通常不错。

4. 功能仿真与测试向量设计

代码写完了,但绝不能直接上板。没有经过充分仿真的硬件设计就像没经过测试的软件,bug百出。我们需要搭建一个测试平台。

4.1 编写Testbench

我们使用SystemVerilog来编写一个更强大的测试平台,它可以方便地产生随机激励并检查结果。

`timescale 1ns/1ps module tb_rr_arbiter(); parameter N = 4; logic clk; logic rst_n; logic [N-1:0] req; logic [N-1:0] grant; logic grant_valid; // 实例化被测设计 rr_arbiter #(.N(N)) u_rr_arbiter ( .clk(clk), .rst_n(rst_n), .req(req), .grant(grant), .grant_valid(grant_valid) ); // 生成时钟 initial begin clk = 0; forever #5 clk = ~clk; // 100MHz时钟 end // 测试过程 initial begin // 初始化 rst_n = 0; req = ‘0; #20; rst_n = 1; #10; // 测试用例1:顺序请求 $display(“[%0t] Test 1: Sequential request“, $time); for (int i = 0; i < N; i++) begin req = (1 << i); // 独热码,只有第i位为1 @(posedge clk); #1; // 等待组合逻辑稳定 check_grant(req, grant, grant_valid); // 自定义检查任务 end // 测试用例2:随机多个请求 $display(“\n[%0t] Test 2: Random multiple requests“, $time); repeat (20) begin std::randomize(req) with {req inside {[1:(1<<N)-1]};}; // 随机非零请求 @(posedge clk); #1; // 检查授权是否在请求中,且是“轮询”预期的那个 // 这里需要根据last_grant的状态来预测,略复杂,可以事后人工查看波形验证 $display(“ req=%4b, grant=%4b, valid=%b“, req, grant, grant_valid); end // 测试用例3:无请求 $display(“\n[%0t] Test 3: No request“, $time); req = ‘0; repeat(3) @(posedge clk); if (grant != 0 || grant_valid != 0) $error(“Error when no request!“); // 测试用例4:请求保持,观察轮询 $display(“\n[%0t] Test 4: Sustained requests to observe round-robin“, $time); req = 4‘b1111; // 所有源持续请求 repeat (10) begin @(posedge clk); #1; $display(“ Cycle %0d: grant=%4b“, $time/10-1, grant); // 预期看到 grant 依次为 0001, 0010, 0100, 1000, 0001... end #100; $finish; end // 简单的检查任务:授权应为独热码且在请求中 task automatic check_grant(input logic [N-1:0] exp_req, input logic [N-1:0] act_grant, input logic act_valid); logic [N-1:0] one_hot_check; one_hot_check = act_grant & (act_grant - 1); // 检查是否为独热码:独热码减1与原码相与结果为0 if (act_valid !== 1‘b1) begin $error(“[%0t] grant_valid should be 1 when req=%4b“, $time, exp_req); end else if (one_hot_check !== 0) begin $error(“[%0t] grant is not one-hot! grant=%4b“, $time, act_grant); end else if ((act_grant & exp_req) == 0) begin $error(“[%0t] grant bit not in req! req=%4b, grant=%4b“, $time, exp_req, act_grant); end else begin $display(“[%0t] PASS: req=%4b -> grant=%4b“, $time, exp_req, act_grant); end endtask endmodule

4.2 仿真结果分析与调试

使用仿真工具(如ModelSim、VCS或开源的Verilator/Icarus Verilog)运行上述testbench。关键是通过波形图观察以下行为:

  1. 复位后状态last_grant是否清零?第一个请求到来时,授权是否给了最低索引的请求源(如req=0001->grant=0001)?这取决于你复位last_grant的逻辑。如果复位为全0,我们的查找逻辑start_idx = (k + 1) % Nlast_grant全0时,k循环找不到为1的位,start_idx会保持初始值0,因此会从索引0开始查找。这符合预期。
  2. 轮询顺序:在测试用例4中,当所有源持续请求时,波形图上的grant信号应该严格按照0001->0010->0100->1000->0001...的顺序循环变化。每个时钟周期变化一次。
  3. 动态请求变化:在随机测试中,观察当req突然变化时,grant是否能在一个时钟周期内正确响应,并且新的last_grant是否被正确更新。
  4. 无请求处理:当req变为全0时,grantgrant_valid是否立刻变为0?last_grant是否保持不变?

调试技巧:如果行为不符合预期,首先检查你的查找逻辑。一个常见的错误是处理last_grant全0的边界条件。另一个错误是在没有请求时grant_valid没有拉低。仔细查看波形,对比reqlast_grant、计算出的start_idx和最终的grant,一步步追踪逻辑。

5. 常见问题、优化与扩展

一个基础的RR调度器工作后,我们来看看实际项目中会遇到哪些问题,以及如何让它变得更强大。

5.1 典型问题与排查清单

问题现象可能原因排查方法
授权grant不是独热码(多位同时为1)组合逻辑中,找到请求后没有及时“跳出”查找循环,导致后续符合条件的请求也被赋值。检查行为级描述中的breakdisable语句是否生效。或者检查是否写成了多个并行的if语句而没有互斥。
grant_valid在无请求时仍为高判断req != 0的逻辑错误,或者grant_valid赋值逻辑没有覆盖所有情况。确保grant_valid的默认值为0,并且仅在req != 0找到有效grant后才被赋值为1。
轮询顺序错乱,不“公平”last_grant更新逻辑错误。例如,在grant_valid为低时也更新了last_grant确认last_grant只在grant_valid为高的时钟沿更新。检查复位值是否影响了初始轮询顺序。
时序违例(建立/保持时间 violation)组合逻辑路径太长,从req/last_grant变化到grant稳定计算出来的延迟超过了时钟周期。1. 查看综合后的时序报告。2. 考虑将部分逻辑流水线化(插入寄存器)。3. 对于大的N,使用“并行前缀”或“矩阵”仲裁结构来优化关键路径。
仿真与综合后行为不一致代码中存在不可综合的语句或对初始化值依赖过多。行为级仿真中的for循环在综合后可能被展开成并行逻辑,若逻辑描述有歧义会导致不同。1. 确保所有always块都使用可综合的编码风格。2. 避免使用初始化变量来决定关键路径。3. 进行门级仿真(SDF反标)以验证综合后网表。

5.2 性能优化方向

  1. 关键路径优化:当N很大(>16)时,我们行为级描述中的循环查找逻辑综合出来可能是一条很长的选择器链,成为时序瓶颈。此时可以采用“并行前缀”算法。基本思想是将N个请求分成两组,先组内仲裁,再组间仲裁,形成一个树状结构,将路径长度从O(N)降低到O(logN)。
  2. 流水线化:如果系统时钟频率要求极高,可以将仲裁逻辑拆分成两拍。第一拍计算upper_reqlower_req的优先级编码结果,第二拍进行最终选择并更新last_grant。这会引入一个周期的调度延迟,但能显著提高最高工作频率。
  3. 面积优化:用二进制索引last_grant_id代替独热码last_grant存储,可以节省触发器。但需要增加一个索引到独热码的解码器,或者修改掩码生成逻辑(使用比较器> last_grant_id)。

5.3 功能扩展:加权轮询与饥饿避免

基础的RR是平等的,但现实世界需要权重。

  • 加权轮询:为每个请求源分配一个权重计数器。每次授权后,该源的计数器减1,直到减到0才移出本轮调度。这需要为每个源增加一个计数寄存器和一个比较逻辑,面积开销较大。
  • 饥饿避免:即使是最公平的RR,如果一个高优先级源持续有请求,低优先级源在它每次被服务后的极短时间内发出请求,也可能被“饿”很多轮。一种改进是“老化”机制,为每个请求记录等待时间,当等待时间超过阈值时,临时提升其优先级。

实现这些扩展会显著增加设计复杂度,需要根据具体应用场景权衡。万变不离其宗,其核心仍然是维护状态(权重、年龄)并基于此状态做出仲裁决策。

从一行行代码到一个在时序约束下稳定工作的硬件模块,实现一个RR调度器的过程,是芯片前端设计思维的典型体现。它要求你将一个抽象的算法,精确地映射到时钟驱动的寄存器传输级电路上,并仔细考量时序、面积和功能正确性。这个简单的调度器,是通往更复杂互连网络、内存控制器和片上系统的基础砖石。当你下次看到grant信号在波形图上规律地跳动时,你会知道,这背后是一整套关于公平与效率的硬件哲学在默默运转。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询