利用块 RAM 中正弦波实现 LED 呼吸效果
我注意到近两年购买的许多小工具已从 LED 闪烁转为 LED 呼吸。多数电子设备含状态 LED,其行为指示设备内部状态。
我的电动牙刷在需充电时闪烁 LED,手机用 LED 提醒各种事项。但 LED 不再像过去那样闪烁,而是呈现模拟脉冲效果,强度连续变化。
下方 GIF 动画展示我的罗技鼠标用此效果指示充电。我称之为 呼吸 LED,因光强变化模式在速度和加速度上类似人类呼吸周期。之所以显得自然,是因为照明周期遵循正弦波模式。

本文延续上周关于脉宽调制 (PWM) 的博文。今天将使用 PWM 模块、锯齿计数器和上篇教程创建的复位模块。点击下方链接阅读 PWM 文章!
如何在 VHDL 中创建 PWM 控制器
在块 RAM 中存储正弦波值
虽可用 FPGA 中的数字信号处理 (DSP) 原语生成正弦波,但更直接的方法是将采样点存入块 RAM。我们在综合时计算一系列正弦值,创建只读存储器 (ROM) 存储它们,而非运行时计算。
看下面最小示例,展示如何在 3×3 位 ROM 中存储完整正弦波。地址输入和数据输出均为 3 位,可表示 0 到 7 的整数。ROM 可存 8 个正弦值,数据分辨率也为 0 到 7。

三角正弦函数对任意角度输入 x 产生 [-1, 1] 区间数值。当 x ≥ 2π 时循环重复。因此只需存储从 0 到不含 2π 的正弦值。2π 处正弦值等于 0 处。上图展示此概念:存储从 0 到 7π/4 的正弦值,即完整 2π 圆前最后一个均匀步进。
数字逻辑无法以无限精度表示角度或正弦值等实数。任何计算机系统皆如此。即使用双精度浮点数,也只是近似。二进制数本质如此,正弦 ROM 也不例外。
为充分利用可用数据位,计算 ROM 内容时给正弦值加偏移和缩放。最小正弦值 -1 映射到数据值 0,最大正弦值 1 映射到 2^{\mathit{data\_bits}-1},公式如下:
\mathit{data} = \mathit{Round}\left(\frac{(1 + \sin \mathit{addr}) * (2^\mathit{data\_bits} - 1)}{2}\right)
将 ROM 地址转为角度 x 可用公式:
x = \frac{\mathit{addr} * 2\pi}{2^\mathit{addr\_bits}}
当然此法不提供通用角度到正弦值转换器。若需此功能,须用额外逻辑或 DSP 原语缩放地址输入和数据输出。但对许多应用,无符号整数表示的正弦波已足够。下节将见此正是 LED 脉冲项目所需。
正弦 ROM 模块
本文呈现的正弦 ROM 模块在大多数 FPGA 架构上将推断块 RAM。建议根据目标 FPGA 的存储原语匹配宽度和深度泛型,以获得最佳资源利用。若不确定如何在真实 FPGA 项目中使用正弦 ROM,可参考示例 Lattice 工程。
在下方表单留邮箱,下载 VHDL 文件和 ModelSim / iCEcube2 工程!
实体
下面代码展示正弦 ROM 模块实体。含两个泛型输入指定推断块 RAM 的宽度和深度。我在常量上用范围说明符防止无意整数溢出,稍后详述。
entity sine_rom is generic ( addr_bits : integer range 1 to 30; data_bits : integer range 1 to 31 ); port ( clk : in std_logic; addr : in unsigned(addr_bits - 1 downto 0); data : out unsigned(data_bits - 1 downto 0) ); end sine_rom;
端口声明有时钟输入,无复位,因 RAM 原语不支持复位。addr 输入赋值缩放角度 (x) 值,data 输出呈现缩放正弦值。
类型声明
VHDL 文件声明区顶部,声明 ROM 存储对象的类型和子类型。addr_range 子类型为等于 RAM 槽位数的整数范围,rom_type 描述存储所有正弦值的二维数组。
subtype addr_range is integer range 0 to 2**addr_bits - 1; type rom_type is array (addr_range) of unsigned(data_bits - 1 downto 0);
但暂不声明存储信号。先定义生成正弦值的函数,以便用函数给 ROM 存储信号赋初值。必须在信号声明上方声明函数。
注意我们用 addr_bits 泛型作 addr_range 基础。故 addr_bits 最大限 30 位。因更大值时 2**addr_bits - 1 计算会溢出。VHDL 整数 32 位,虽 VHDL-2019 将用 64 位,但目前工具支持前仍受此限制。
生成正弦值的函数
下面代码展示生成正弦值的 init_rom 函数。它返回 rom_type 对象,故须先声明类型、再声明函数、最后声明 ROM 常量,依赖顺序固定。
function init_rom return rom_type is variable rom_v : rom_type; variable angle : real; variable sin_scaled : real; begin for i in addr_range loop angle := real(i) * ((2.0 * MATH_PI) / 2.0**addr_bits); sin_scaled := (1.0 + sin(angle)) * (2.0**data_bits - 1.0) / 2.0; rom_v(i) := to_unsigned(integer(round(sin_scaled)), data_bits); end loop; return rom_v; end init_rom;
函数用便利变量,含 rom_v、填充正弦值的数组局部副本。子程序内用 for 循环遍历地址范围,每个 ROM 槽按前述公式计算正弦值。最后返回包含所有正弦采样的 rom_v 变量。
for 循环最后一行的整数转换是限制 data_bits 泛型至 31 位的原因。更大位宽会溢出。
constant rom : rom_type := init_rom;
init_rom 函数定义下,声明 rom 对象为常量。ROM 就是只读的 RAM,完全可行。现在调用 init_rom 生成初始值,如上代码所示。
ROM 进程
正弦 ROM 文件中唯一逻辑是下面列出的简单进程。描述单读端口块 RAM。
ROM_PROC : process(clk) begin if rising_edge(clk) then data <= rom(to_integer(addr)); end if; end process;
顶层模块
本设计延续上篇 PWM 项目。含复位模块、PWM 发生器模块、自由运行时钟周期计数器 (锯齿计数器) 模块。请参考上周文章了解这些模块工作原理。
下图展示顶层模块中子模块连接。

下面代码展示顶层 VHDL 文件声明区。上周 PWM 设计中 duty_cycle 对象是 cnt 计数器 MSB 的别名。但现在不行,因正弦 ROM 输出控制占空比,故改为实际信号。且新建别名 addr 为计数器 MSB,作为 ROM 地址输入。
signal rst : std_logic; signal cnt : unsigned(cnt_bits - 1 downto 0); signal pwm_out : std_logic; signal duty_cycle : unsigned(pwm_bits - 1 downto 0); -- Use MSBs of counter for sine ROM address input alias addr is cnt(cnt'high downto cnt'length - pwm_bits);
下面列表展示如何在顶层模块实例化新正弦 ROM。设 RAM 宽度和深度跟随 PWM 模块内部计数器长度。ROM 的 data 输出控制 PWM 模块的 duty_cycle 输入。依次读取 RAM 槽位时,duty_cycle 信号值将呈现正弦波模式。
SINE_ROM : entity work.sine_rom(rtl) generic map ( data_bits => pwm_bits, addr_bits => pwm_bits ) port map ( clk => clk, addr => addr, data => duty_cycle );
仿真正弦波 ROM
下图显示 ModelSim 中顶层模块仿真波形。我将无符号 duty_cycle 信号显示格式改为模拟格式以观察正弦波。

顶层输出 led_5 携带控制外部 LED 的 PWM 信号。可见占空比上升或下降时输出快速变化。但占空比在正弦波顶峰时 led_5 稳定为 '1';波谷时输出短暂保持 '0'。
想在自家电脑试试?在下方表单填邮箱,接收 VHDL 文件和 ModelSim 与 iCEcube2 工程!
在 FPGA 实现 LED 呼吸
我用 Lattice iCEcube2 软件在 iCEstick FPGA 板实现设计。上方表单下载工程,若有 iCEstick 可自行尝试!
下面列表显示 Synplify Pro (iCEcube2 自带) 报告的资源使用。显示设计使用一个块 RAM 原语,即我们的正弦 ROM。
Resource Usage Report for led_breathing Mapping to part: ice40hx1ktq144 Cell usage: GND 4 uses SB_CARRY 31 uses SB_DFF 5 uses SB_DFFSR 39 uses SB_GB 1 use SB_RAM256x16 1 use VCC 4 uses SB_LUT4 65 uses I/O ports: 7 I/O primitives: 7 SB_GB_IO 1 use SB_IO 6 uses I/O Register bits: 0 Register bits not including I/Os: 44 (3%) RAM/ROM usage summary Block Rams : 1 of 16 (6%) Total load per clock: led_breathing|clk: 1 @S |Mapping Summary: Total LUTs: 65 (5%)
iCEcube2 布线后,led_breathing_Implmnt\sbt\outputs\bitmap 文件夹内 Lattice_iCEcube2_proj 工程目录下可找到 .bin 文件。
可用 Lattice Diamond Programmer Standalone 软件编程 FPGA,参考 iCEstick 用户手册。我这么做了,下方 GIF 动画展示结果。LED 光强随正弦波模式振荡,看起来很自然,若发挥想象,LED 仿佛在「呼吸」。

结语
用块 RAM 存预计算正弦值相当直接。但有几个限制使此法仅适用于 X 和 Y 分辨率有限的正弦波。
首先是前述 32 位整数限制。我相信可用更巧妙计算正弦值解决,但不止于此。
ROM 地址每扩展 1 位,RAM 用量翻倍。若需 X 轴高精度,即使大 FPGA 也可能 RAM 不足。
若 Y 轴正弦值位数超原生 RAM 深度,综合工具将用额外 RAM 或 LUT 实现 ROM,随 Y 精度增加消耗更多资源预算。
理论上只需存储正弦波一个象限。若用有限状态机 (FSM) 控制 ROM 读出,可用 1/4 RAM 用量。需对 X 和 Y 轴所有四种排列反转正弦象限,从单象限构建完整正弦波。
遗憾的是难让四段平滑衔接。顶峰和波谷连接处的两个相等采样会产生平坦点,扭曲数据。引入噪声违背了仅存象限以提高正弦波精度的初衷。
VHDL
- 打造零摩擦用户体验:UX 设计蓝图与实战五原则
- 通过 OPC UA 客户端调用 PLC 功能块:实用指南
- 数字化制造重塑现代医学解剖建模:从训练到术前规划的范式跃迁
- 构建您的第一个AWS CloudFormation模板——实操指南
- VHDL 字符串列表创建:最佳实践与示例
- 基于 Tcl 驱动的 VHDL 密码锁模块测试平台
- VHDL 实现 PWM 控制器:设计、仿真与 FPGA 演示
- 用 VHDL TEXTIO 库从文本文件初始化 FPGA 块 RAM
- 基于 Ready/Valid 握手在块 RAM 中实现 AXI FIFO
- 构建VHDL自检测试平台实战指南
- 用VHDL受保护类型和访问指针实现动态链表
- Java 对象数组创建:分步指南