本页面属于一个小项目系列,这些项目用于探索 Smart Zynq 开发板的各种功能。
简介
市面上有几种结构简单、价格低廉的直流舵机,例如 SG90、MG90S、MG995 和 MG996R。这些舵机用于业余爱好项目,特别是制作简易机器人。这类舵机通过单一的 PWM 信号控制。
有些舵机只能在有限的角度范围内旋转,通常是 180 度。对这种舵机,PWM 信号控制舵机的角位置。另一些舵机(常被称为“360 度舵机”)可以连续旋转。对这种舵机,PWM 信号控制旋转的速度和方向。注意,同一个型号的舵机常常有两种版本:一种限制转动角度,另一种可以连续旋转。购买这类舵机时,留意这一区别很重要。
本教程展示如何将最多八个舵机连接到 Smart Zynq 开发板,并借助一条简单的 Linux 命令控制这些舵机。本教程还演示了如何利用 Xillybus 的可寻址数据流(seekable streams)来实现 FPGA 内部的寄存器接口。
为了让描述和照片保持简单,本教程中的电气接线只针对一个舵机。要增加更多舵机,只需按图上所示,在同样的元器件上增加导线即可。
舵机的电气接口
这类舵机的接线共有三根导线:
- 棕色线,连接到地。
- 红色线,连接到 +5V 电源。
- 橙色线,连接到控制信号(PWM)。
PWM 信号的周期应为 20 ms(50 Hz)。脉冲保持高电平的持续时间控制舵机的位置或角速度。
按照大多数数据手册,脉冲持续时间应为 1 ms 到 2 ms。对某些舵机来说,这个信息并不正确。例如,Tower Pro 的 SG90(限制在 180 度转动)的正确范围大约是 500 μs 到 2450 μs。这个范围对应 180 度的转动。如果脉冲最长到 2560 μs,还能让舵机略微转得更远一些。SG90 会忽略比这更长的脉冲。
因此,为了探索舵机实际响应的脉冲宽度范围,最好亲自用舵机做实验,而不要只依赖数据手册。由于这类舵机面向业余爱好项目,其规格数据未必精确。
电气连接
首先,舵机需要独立的 +5V 电源来为内部的电机供电。不建议使用给 Smart Zynq 开发板供电的电源,因为舵机经常会引起电压的突然波动。如果供电电压不稳定,Smart Zynq 开发板可能会工作不可靠。
橙色线上的 PWM 信号在高电平时,其电压应大致等于电源电压。换句话说,橙色线与地之间的电压要么是 0,要么大约是 +5V。
然而,Smart Zynq 开发板的输出高电平电压只有 3.3V。如果把这个输出直接接到舵机的橙色线,舵机很有可能仍能正常响应。原因是,基于 5V 电源供电的数字电路通常把任何高于 2.5V 的电压都视为逻辑 1。
话虽如此,PWM 信号电压为 3.3V 时,舵机能否可靠工作还不确定。推荐的解决方案是使用电压电平转换器(voltage level shifter),把 Smart Zynq 开发板的输出转换成 0V 或 5V。本教程使用了一块带有 TXS0108E 芯片的小板。这块小板只是把芯片的引脚引出来,便于用普通导线连接到芯片。
这张图展示了控制单个舵机时的接线方式:图中底部的 Smart Zynq 开发板通过杜邦线连接到 TXS0108E 小板。这块小板的另一侧连接到舵机的 PWM 输入,也连接到舵机专用的独立电源(通过一个标准电源插头的转接头)。舵机的红色和棕色线也焊接到这块小板上,以便给舵机提供电源电压。
这张图更近距离地展示了 TXS0108E 小板及其连接:
注意 OE 与 VA 之间的红色导线。它确保芯片的输出使能(output enable)为高电平。
与 Smart Zynq 排针的连接如下图所示。按照这里的接法,舵机由 J6/1 控制。
下表总结了四个相关部分之间的连接关系:
| TXS0108E 小板引脚 | Smart Zynq 引脚 | 舵机导线 | +5V 电源 | 说明 |
|---|---|---|---|---|
| VA | J6/37(3.3V) | -- | -- | 还与 OE 相连 |
| A4 | J6/1 | -- | -- | |
| OE | -- | -- | -- | 与 VA 相连 |
| GND | J6/35(GND) | 棕色线 | GND | |
| VB | -- | 红色线 | +5V | |
| B4 | -- | 橙色线 | -- |
TXS0108E 芯片负责把电压电平从它的 A4 输入转换到 B4 输出。芯片上还有另外七对引脚。A4/B4 这一对是本教程随意选择的。要控制八个舵机,可以使用这个芯片的全部八对引脚。这里没有展示这种方式,因为控制八个舵机所需的导线数量会让照片难以看懂。
要找到 J6 排针,请在 Smart Zynq 开发板背面找到印有 “Bank 33 VCCIO Vadj” 的位置。靠近这个标记的那一排引脚就是本教程使用的排针。因此,J6/1 就是最靠近 HDMI 连接器的那个引脚。
注意,Smart Zynq 开发板通过它的一个 USB 端口连接到另一路电源。还要注意,J6 排针的最后一个引脚是 5V,所以不要把导线接到这个引脚上。
准备 Vivado 工程
从演示包(demo bundle)的 zip 文件(boot partition kit)创建新的 Vivado 工程。用文本编辑器打开 verilog/src/xillydemo.v。删除代码中标有 “PART 4” 的部分,用下面这段代码替换它:
assign user_r_mem_8_empty = 0;
assign user_r_mem_8_eof = 0;
assign user_w_mem_8_full = 0;
reg [7:0] reg0, reg1, reg2, reg3, reg4, reg5, reg6, reg7;
always @(posedge bus_clk)
if (user_w_mem_8_wren)
case (user_mem_8_addr[2:0])
0: reg0 <= user_w_mem_8_data;
1: reg1 <= user_w_mem_8_data;
2: reg2 <= user_w_mem_8_data;
3: reg3 <= user_w_mem_8_data;
4: reg4 <= user_w_mem_8_data;
5: reg5 <= user_w_mem_8_data;
6: reg6 <= user_w_mem_8_data;
7: reg7 <= user_w_mem_8_data;
endcase
reg [7:0] mem_8_data_reg;
assign user_r_mem_8_data = mem_8_data_reg;
always @(posedge bus_clk)
if (user_r_mem_8_rden)
case (user_mem_8_addr[2:0])
0: mem_8_data_reg <= reg0;
1: mem_8_data_reg <= reg1;
2: mem_8_data_reg <= reg2;
3: mem_8_data_reg <= reg3;
4: mem_8_data_reg <= reg4;
5: mem_8_data_reg <= reg5;
6: mem_8_data_reg <= reg6;
7: mem_8_data_reg <= reg7;
endcase
servo_pwm servo_pwm_i [7:0]
(
.clk(bus_clk),
.rst(quiesce),
.pwm_signal(J6[7:0]),
.pwm_width( { reg7, reg6, reg5, reg4, reg3, reg2, reg1, reg0 } )
);
也可以从这个链接下载更新后的 xillydemo.v。
在工程目录的 verilog/src/ 下创建一个新文件 servo_pwm.v,内容如下(可以复制粘贴,也可以从这个链接下载):
module servo_pwm
(
input clk,
input rst,
output reg pwm_signal,
input [7:0] pwm_width
);
reg [9:0] enable_count;
reg enable;
reg [10:0] pwm_count;
reg [8:0] threshold;
always @(posedge clk)
begin
if (enable_count == 999)
begin
enable_count <= 0;
enable <= 1;
end
else
begin
enable_count <= enable_count + 1;
enable <= 0;
end
threshold <= pwm_width + 50; // Add 0.5 ms to PWM width
if (enable)
begin
if (pwm_count == 1999)
pwm_count <= 0;
else
pwm_count <= pwm_count + 1;
pwm_signal <= (pwm_count < threshold);
end
if (rst)
begin
enable_count <= 0;
pwm_count <= 0;
pwm_signal <= 0;
end
end
endmodule
然后把这个文件添加到 Vivado 工程中:点击 File > Add Sources…,选择 “Add or create design sources”。再点击 Next。点击 “Add Files” 按钮,在 verilog/src/ 目录下选择名为 servo_pwm.v 的文件。最后点击 “Finish” 按钮。
用更新后的工程生成比特流(bitstream)文件,方法与为演示包生成比特流文件时相同。同时,把比特流文件复制到 TF 卡中(用这个工程生成的文件覆盖旧的 xillydemo.bit 文件)。
控制舵机
本节中的这些操作应该在 Smart Zynq 开发板的 shell 提示符下执行。
首先,切换目录并对 Xillybus 的演示应用程序(demoapps)进行编译:
# cd ~/xillybus/demoapps/ # make gcc -g -Wall -O3 memwrite.c -o memwrite gcc -g -Wall -O3 memread.c -o memread gcc -g -Wall -O3 streamread.c -o streamread gcc -g -Wall -O3 streamwrite.c -o streamwrite gcc -g -Wall -O3 -pthread fifo.c -o fifo
FPGA 中有八个用于控制舵机的寄存器。初始时,它们的值全部为零。下面这条命令以十进制格式打印这些值:
# hexdump -v -n 8 -e '8/1 "%u " "\n" ' /dev/xillybus_mem_8 0 0 0 0 0 0 0 0
也可以用更简单的 hexdump 命令以十六进制格式打印同样的值:
# hexdump -v -n 8 -C /dev/xillybus_mem_8 00000000 00 00 00 00 00 00 00 00 |........| 00000008
可以使用位于 xillybus/demoapps/ 目录下的 memwrite 程序来控制舵机。例如,下面的命令把寄存器 0 的值改为 120:
# ./memwrite /dev/xillybus_mem_8 0 120
这会把连接到 J6/1 的舵机(如上文照片所示)的转动位置或速度改变。
PWM 脉冲宽度与该寄存器的值之间满足下面的公式:
t = 500 + (x * 10)
在这个公式中,t 以微秒为单位,x 是相关寄存器的值。因此,脉冲的默认宽度为 500 μs(即 x = 0 时)。上面的命令把寄存器值改为 120,因此脉冲宽度变为 1700 μs。
每个寄存器由一个字节组成,因此值的范围是 0 到 255。相应地,每个舵机的脉冲宽度可以在 500 μs 到 3050 μs 之间调节。注意,这个范围中有相当一部分会被大多数舵机视为非法值。最好针对每个舵机尝试不同取值,观察它的反应。
要控制连接到其他引脚的舵机,就向另一个寄存器写入。例如,如果舵机连接到 Smart Zynq 的 J6/4,可以使用下面的命令:
# ./memwrite /dev/xillybus_mem_8 3 50
这条命令把脉冲宽度改为 1000 μs(即 1 ms)。
执行这两条命令之后,可以通过读回这些值来查看改动:
# hexdump -v -n 8 -e '8/1 "%u " "\n" ' /dev/xillybus_mem_8 120 0 0 50 0 0 0 0
上面以十进制格式显示这些值。十六进制格式如下:
# hexdump -v -n 8 -C /dev/xillybus_mem_8 00000000 78 00 00 32 00 00 00 00 |x..2....| 00000008
memwrite 的工作原理
memwrite 的源代码就是 ~/xillybus/demoapps/ 目录下的 memwrite.c 文件。这个程序展示了如何借助 Xillybus 的可寻址数据流(seekable streams)来访问寄存器。如果你想理解这个程序的工作原理,建议直接查看其源代码。下面我只讲解两个重要部分。
程序会打开第一个参数指定的文件。这个文件对应的文件描述符存放在变量 @fd 中。
然后,程序调用 lseek(),具体如下:
if (lseek(fd, address, SEEK_SET) < 0) {
perror("Failed to seek");
exit(1);
}
@address 变量保存程序的第二个参数。在前面的第一个 memwrite 示例中,它是 0;在第二个示例中,它是 3。通常,lseek() 用来移动到文件内的某个特定位置。在本例中,文件中的位置就是我们要访问的寄存器的编号。
接下来,程序调用 allwrite():
allwrite(fd, &data, 1);
这段代码向文件写入一个字节。allwrite() 定义在 memwrite.c 中,它与众所周知的 write() 函数相似。因此,把 allwrite() 换成下面这样也差不多:
write(fd, &data, 1);
两者的区别是,write() 不保证数据一定写入成功。而 allwrite() 则保证数据会被写入文件。
在这里,调用 allwrite() 写入的是程序第三个参数的值。换句话说,这就是寄存器的新值。
关于 Xillybus 主机端 API 的更多细节,请参阅相关文档,特别是其中 6.1 节。
Verilog 代码解读
我先介绍 xillydemo.v 中的 Verilog 代码如何实现 FPGA 内部的寄存器,然后再介绍 PWM 脉冲的实现。
关于如何用 Xillybus API 实现硬件寄存器的详细说明,参见Xillybus FPGA 设计指南。
与寄存器相关的连线在原始 xillydemo.v 文件中已经定义好了:
// Wires related to /dev/xillybus_mem_8
wire user_r_mem_8_rden;
wire user_r_mem_8_empty;
wire [7:0] user_r_mem_8_data;
wire user_r_mem_8_eof;
wire user_r_mem_8_open;
wire user_w_mem_8_wren;
wire user_w_mem_8_full;
wire [7:0] user_w_mem_8_data;
wire user_w_mem_8_open;
wire [4:0] user_mem_8_addr;
wire user_mem_8_addr_update;
这些连线在 Xillybus IP 核实例化时连接到该 IP 核:
xillybus xillybus_ins (
// Ports related to /dev/xillybus_mem_8
// FPGA to CPU signals:
.user_r_mem_8_rden(user_r_mem_8_rden),
.user_r_mem_8_empty(user_r_mem_8_empty),
.user_r_mem_8_data(user_r_mem_8_data),
.user_r_mem_8_eof(user_r_mem_8_eof),
.user_r_mem_8_open(user_r_mem_8_open),
// CPU to FPGA signals:
.user_w_mem_8_wren(user_w_mem_8_wren),
.user_w_mem_8_full(user_w_mem_8_full),
.user_w_mem_8_data(user_w_mem_8_data),
.user_w_mem_8_open(user_w_mem_8_open),
// Address signals:
.user_mem_8_addr(user_mem_8_addr),
.user_mem_8_addr_update(user_mem_8_addr_update),
[ ... ]
.quiesce(quiesce)
);
接下来,我们看看为这个项目专门添加的 Verilog 代码。代码的第一部分确保与主机的数据交换不使用流控(flow control):
assign user_r_mem_8_empty = 0;
assign user_r_mem_8_eof = 0;
assign user_w_mem_8_full = 0;
接下来声明八个寄存器:
reg [7:0] reg0, reg1, reg2, reg3, reg4, reg5, reg6, reg7;
下一部分实现寄存器的写操作:当 @user_w_mem_8_wren 为高时,新值写入其中一个寄存器。@user_mem_8_addr 选择写入哪个寄存器。@user_w_mem_8_wren 和 @user_mem_8_addr 都是 Xillybus IP 核的输出。
always @(posedge bus_clk)
if (user_w_mem_8_wren)
case (user_mem_8_addr[2:0])
0: reg0 <= user_w_mem_8_data;
1: reg1 <= user_w_mem_8_data;
2: reg2 <= user_w_mem_8_data;
3: reg3 <= user_w_mem_8_data;
4: reg4 <= user_w_mem_8_data;
5: reg5 <= user_w_mem_8_data;
6: reg6 <= user_w_mem_8_data;
7: reg7 <= user_w_mem_8_data;
endcase
接下来实现从寄存器读值:当 @user_r_mem_8_rden 为高时,@user_r_mem_8_data 会更新为某个寄存器的值。@user_mem_8_addr 选择读取哪个寄存器的值。
reg [7:0] mem_8_data_reg;
assign user_r_mem_8_data = mem_8_data_reg;
always @(posedge bus_clk)
if (user_r_mem_8_rden)
case (user_mem_8_addr[2:0])
0: mem_8_data_reg <= reg0;
1: mem_8_data_reg <= reg1;
2: mem_8_data_reg <= reg2;
3: mem_8_data_reg <= reg3;
4: mem_8_data_reg <= reg4;
5: mem_8_data_reg <= reg5;
6: mem_8_data_reg <= reg6;
7: mem_8_data_reg <= reg7;
endcase
最后,对 servo_pwm 模块进行实例化(instantiation):
servo_pwm servo_pwm_i [7:0]
(
.clk(bus_clk),
.rst(quiesce),
.pwm_signal(J6[7:0]),
.pwm_width( { reg7, reg6, reg5, reg4, reg3, reg2, reg1, reg0 } )
);
注意,这个实例化会创建 servo_pwm 的八个完全相同副本:第一个副本连接到 reg0 和 J6[0],第二个副本连接到 reg1 和 J6[1],依此类推。
下面来看 servo_pwm 模块。模块首先声明其端口和寄存器:
module servo_pwm
(
input clk,
input rst,
output reg pwm_signal,
input [7:0] pwm_width
);
reg [9:0] enable_count;
reg enable;
reg [10:0] pwm_count;
reg [8:0] threshold;
回顾前文:@pwm_width 接收寄存器的值,这个值由主机上的 memwrite 命令更新。@pwm_signal 连接到 Smart Zynq 开发板的引脚,因此这就是通过橙色线到达舵机的信号。
这个模块的下一部分实现一个选通信号(strobe):@enable 每 1000 个时钟周期为高一次。@clk 的频率为 100 MHz,因此 @enable 每 10 μs 为高一次。
always @(posedge clk)
begin
if (enable_count == 999)
begin
enable_count <= 0;
enable <= 1;
end
else
begin
enable_count <= enable_count + 1;
enable <= 0;
end
如前所述,寄存器值从 0 到 255 对应 PWM 脉冲宽度为 500 μs 到 3050 μs(脉冲宽度以微秒为单位时的表达式是 500 + x * 10)。
因此,@threshold 的表达式如下:
threshold <= pwm_width + 50; // Add 0.5 ms to PWM width
这个寄存器保存 PWM 脉冲(即 PWM 信号为高电平期间)中 @enable 为高的次数。这个表达式反映了一个事实:寄存器的值就是 PWM 脉冲长度,以 10 μs 为单位。给寄存器值加上 50,对应最小脉冲宽度 500 μs。
现在,@enable 被用作这部分逻辑的时钟使能(clock enable):
if (enable)
begin
if (pwm_count == 1999)
pwm_count <= 0;
else
pwm_count <= pwm_count + 1;
pwm_signal <= (pwm_count < threshold);
end
@pwm_count 从 0 计数到 1999,然后重新开始。它每 10 μs 才改变一次,因此每 20000 μs = 20 ms 完成一个完整周期。换句话说,PWM 脉冲的重复周期是 20 ms,满足要求。
只要 @pwm_count 小于 @threshold,@pwm_signal 就为高。这就是寄存器控制脉冲宽度的方式。
模块的最后一部分用于复位某些寄存器:
if (rst)
begin
enable_count <= 0;
pwm_count <= 0;
pwm_signal <= 0;
end
end
endmodule
当 @rst 为高时,这段逻辑会覆盖上面任何赋值。因此,@rst 起复位(reset)信号的作用。
Verilog 代码与实际引脚的对应关系
上面的 Verilog 代码使用名为 J6 的 inout 端口,但这些连接是如何到达排针的呢?答案可以在 xillydemo.xdc 中找到。这个文件是生成比特流(bitstream)的 Vivado 工程的一部分(位于 vivado-essentials 目录)。
xillydemo.xdc 包含多种使 FPGA 作为电子元器件正常工作所需的信息。除此之外,该文件还包含下面这些行:
[ ... ]
## J6 on board (BANK33 VADJ)
set_property PACKAGE_PIN U22 [get_ports {J6[0]}]; #J6/1 = IO_B33_LN2
set_property PACKAGE_PIN T22 [get_ports {J6[1]}]; #J6/2 = IO_B33_LP2
set_property PACKAGE_PIN W22 [get_ports {J6[2]}]; #J6/3 = IO_B33_LN3
set_property PACKAGE_PIN V22 [get_ports {J6[3]}]; #J6/4 = IO_B33_LP3
set_property PACKAGE_PIN Y21 [get_ports {J6[4]}]; #J6/5 = IO_B33_LN9
set_property PACKAGE_PIN Y20 [get_ports {J6[5]}]; #J6/6 = IO_B33_LP9
set_property PACKAGE_PIN AB22 [get_ports {J6[6]}]; #J6/7 = IO_B33_LN7
set_property PACKAGE_PIN AA22 [get_ports {J6[7]}]; #J6/8 = IO_B33_LP7
[ ... ]
第一行表示,端口 J6[0] 应连接到 U22,这是 FPGA 物理封装上的一个位置。根据 Smart Zynq 的原理图,这个 FPGA 引脚连接排针的第一个引脚。其他端口的位置也以同样方式定义。
总结
本教程展示了如何在 Smart Zynq 开发板和 Xillybus 寄存器 API 的帮助下控制舵机。文中给出了主机应用程序以及相应的 Verilog 代码。
对于其他需要通过寄存器控制硬件的应用,本教程也可以作为基础参考。


