项目需求
- 设计一个UI控制程序
- 通过UART进行数据传输
- 数码管显示
- 拨动开关设计
- 按键调节颜色值
- 最后进行功能整合
需求分析
1. 界面设计要求
- 设计一个用户友好的PC界面,用于控制RGB三种颜色的数值范围。
- 界面应包含三个滑动条或输入框,分别对应R、G、B的数值范围(0-255)进行调节。
2. 数据传递要求
- 调节好的RGB颜色数值通过UART传递到小脚丫FPGA上,用于控制三色LED的状态。
3. 数码管显示要求
- 根据拨动开关SW1-SW3的选择,将对应颜色值显示在数码管上。
- 数码管应能够显示0-255范围内的数值。
4. 拨动开关和颜色选择要求
- 使用FPGA上的拨动开关SW1~SW3选择要在数码管上显示的颜色值。
- 拨动开关与颜色对应关系:
- SW1:红色R
- SW2:绿色G
- SW3:蓝色B
5. 颜色值调节要求
- 使用FPGA上的轻触开关K1和K2,控制拨动开关选择的颜色值的调节。
- 调节方式:
- K1: 颜色值+1
- K2: 颜色值-1
6. 功能整合要求
- 通过PC界面控制任意一种颜色,改变FPGA上三色LED的显示。
- 数码管显示的颜色值根据拨动开关SW1-3的状态选择。
- 实现通过R、G、B三种颜色的调节生成白色效果,并记录生成白色时的R、G、B三种颜色数值。
UART交互协议
为了和上位机通信,设计了一个简单的UART通信协议。每包固定7个字节。UART通信参数:115200, 8N1
1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|
> or < | CMD | DATA1 | DATA2 | DATA3 | DATA4 | AA |
帧头 | 命令 | 数据1 | 数据2 | 数据3 | 数据4 | 帧尾 |
说明:帧头 >
0x3E 表示FPGA接收数据,<
0x3C 表示FPGA发送数据
1. RGB LED控制
方向:上位机 -> 下位机
1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|
> | CMD | DATA1 | DATA2 | DATA3 | DATA4 | AA |
帧头0x3E | 0x01 | Red(0x00-0xFF) | Green | Blue | 0x00 | 帧尾 |
2. 状态同步
下位机将自己的状态实时同步给上位机,比如通过按键设置了RGB LED的值需要同步给上位机,上位机需要同步更新UI界面。
方向:下位机 -> 上位机
1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|
< | CMD | DATA1 | DATA2 | DATA3 | DATA4 | AA |
帧头0x3C | 0x71 | Red(0x00-0xFF) | Green | Blue | 0x00 | 帧尾 |
上位机和FPGA通信示意图
FPGA实现
开源地址:https://gitee.com/bigstep/step-fpga.git
功能框图
UART硬件连接
UART使用了三根线GND
,GPIO15(N8 TX)
,GPIO14(P8 RX)
,如下图所示
TOP RTL视图
主要分为
- LED呼吸灯作为程序运行状态指示
- UART数据接收模块
- UART指令发送模块
- 核心业务控制模块
- RGB显示核心控制模块
仿真
虽然ModelSim
功能更强大,但是用起来还是有点麻烦。一般情况下我们可以选择轻量级的开源免费软件 Icarus Verilog 进行仿真。
hdlbits
在线仿真
使用hdlbits
在线进行测试更方便 https://hdlbits.01xz.net/wiki/Iverilog
网页测试简单的代码比较方便,但是一旦仿真时间长就会显示不全,所以我们还需要在本地安装
Icarus Verilog
Windows电脑安装Icarus Verilog
,下载地址 https://bleyer.org/icarus/
注意:安装的时候勾选将bin加入系统PATH中,方便在命令行中直接调用可执行文件。
下面的仿真都是通过这个工具实现的。
呼吸灯
作为程序运行的状态指示,通过调节PWM占空比控制灯的明亮程度。
仿真测试
# 编译,默认生成a.out文件
iverilog breath_led_tb.v breath_led.v
# 生成vcd文件
vvp .\a.out
# 使用gtkwave软件打开vcd波形图
gtkwave.exe .\breath_led.vcd
仿真测试文件
`timescale 1ns/1ns
module top_module ();
reg clk=0;
always #10 clk = ~clk; // Create clock with period=20
// A testbench
reg rst_n=0;
wire led_out;
initial begin
#50 rst_n <= 1'b1;
$display ("The current time is (%0d ns)", $time);
#10000000 $finish; // Quit the simulation
end
breath_led breath_led (
.clk(clk),
.rst_n(rst_n),
.led_out(led_out)
);
initial begin
$dumpfile("breath_led.vcd");
$dumpvars(0, top_module);
end
endmodule
可以看出随着时间的增长,`led_out`输出时间越来越长,也就是占空比越来越高。
UART命令解析模块
带指令接收超时重置功能,通过 recv_cnt
计数,100毫秒没接收到完整包即认为数据出错,重置参数为下一次接收作准备。
reg [3:0] recv_state;
reg [31:0] recv_cnt; // recv timeout timer
always @(posedge clk, negedge rst_n) begin
if (!rst_n) begin
recv_state <= 4'd0;
recv_cnt <= 32'd0;
cmd_recv <= 1'b0;
end else if (recv_cnt > 32'd4_999_999) begin
recv_state <= 4'd0;
recv_cnt <= 32'd0;
cmd_recv <= 1'b0;
end else begin
recv_cnt <= recv_cnt + 1'b1;
case (recv_state)
4'd0: begin // > 0x3E
cmd_recv <= 1'b0;
if (uart_rx_done) begin
if (uart_rx_byte == 8'h3E) begin
recv_state <= 4'd1;
recv_cnt <= 32'd0;
end else begin
recv_state <= 4'd0;
end
end else begin
recv_state <= 4'd0;
recv_cnt <= 32'd0;
end
end
4'd1: begin // cmd
if (uart_rx_done) begin
rx_cmd_data[7:0] <= uart_rx_byte;
recv_state <= 4'd2;
recv_cnt <= 32'd0;
end else recv_state <= recv_state;
end
4'd2: begin // data1
if (uart_rx_done) begin
rx_cmd_data[15:8] <= uart_rx_byte;
recv_state <= 4'd3;
recv_cnt <= 32'd0;
end else recv_state <= recv_state;
end
4'd3: begin // data2
if (uart_rx_done) begin
rx_cmd_data[23:16] <= uart_rx_byte;
recv_state <= 4'd4;
recv_cnt <= 32'd0;
end else recv_state <= recv_state;
end
4'd4: begin // data3
if (uart_rx_done) begin
rx_cmd_data[31:24] <= uart_rx_byte;
recv_state <= 4'd5;
recv_cnt <= 32'd0;
end else recv_state <= recv_state;
end
4'd5: begin // data4
if (uart_rx_done) begin
rx_cmd_data[39:32] <= uart_rx_byte;
recv_state <= 4'd6;
recv_cnt <= 32'd0;
end else recv_state <= recv_state;
end
4'd6: begin // AA
if (uart_rx_done) begin
if (uart_rx_byte == 8'hAA) begin
// recv cmd success
cmd_recv <= 1'b1;
end
recv_state <= 4'd0;
end else recv_state <= recv_state;
end
default: recv_state <= 4'd0;
endcase
end
end
仿真测试
进入 tb/uart_rx_cmd
目录,在命令行中执行如下命令
# 编译,默认生成a.out文件
iverilog uart_rx_cmd_tb.v uart_rx.v uart_rx_cmd.v
# 生成uart_tx.vcd文件
vvp .\a.out
# 使用gtkwave软件打开uart_tx.vcd波形图
gtkwave.exe .\uart_rx_cmd.vcd
模拟一条执行的正常接收:3E01020304055AA
,从仿真结果可以看到 cmd_recv
最后拉高一个时钟,代表接收到合法的数据帧。结果保存在rx_cmd_data
中
`timescale 1ns/1ns
module top_module ();
reg clk=0;
always #10 clk = ~clk; // Create clock with period=20
// A testbench
reg rst_n=0;
reg rx=1;
wire [7:0] rx_data;
wire cmd_recv;
wire [39:0] rx_cmd_data;
initial begin
#50 rst_n <= 1'b1;
#50 rx=0; // START
#8680 rx=0; // bit0 0x3E
#8680 rx=1; // bit1
#8680 rx=1; // bit2
#8680 rx=1; // bit3
#8680 rx=1; // bit4
#8680 rx=1; // bit5
#8680 rx=0; // bit6
#8680 rx=0; // bit7
#8680 rx=1; // STOP
#8680 rx=1; // IDLE
#50 rx=0; // START
#8680 rx=1; // bit0 0x01
#8680 rx=0; // bit1
#8680 rx=0; // bit2
#8680 rx=0; // bit3
#8680 rx=0; // bit4
#8680 rx=0; // bit5
#8680 rx=0; // bit6
#8680 rx=0; // bit7
#8680 rx=1; // STOP
#8680 rx=1; // IDLE
#50 rx=0; // START
#8680 rx=0; // bit0 0x02
#8680 rx=1; // bit1
#8680 rx=0; // bit2
#8680 rx=0; // bit3
#8680 rx=0; // bit4
#8680 rx=0; // bit5
#8680 rx=0; // bit6
#8680 rx=0; // bit7
#8680 rx=1; // STOP
#8680 rx=1; // IDLE
#50 rx=0; // START
#8680 rx=1; // bit0 0x03
#8680 rx=1; // bit1
#8680 rx=0; // bit2
#8680 rx=0; // bit3
#8680 rx=0; // bit4
#8680 rx=0; // bit5
#8680 rx=0; // bit6
#8680 rx=0; // bit7
#8680 rx=1; // STOP
#8680 rx=1; // IDLE
#50 rx=0; // START
#8680 rx=0; // bit0 0x04
#8680 rx=0; // bit1
#8680 rx=1; // bit2
#8680 rx=0; // bit3
#8680 rx=0; // bit4
#8680 rx=0; // bit5
#8680 rx=0; // bit6
#8680 rx=0; // bit7
#8680 rx=1; // STOP
#8680 rx=1; // IDLE
#50 rx=0; // START
#8680 rx=1; // bit0 0x05
#8680 rx=0; // bit1
#8680 rx=1; // bit2
#8680 rx=0; // bit3
#8680 rx=0; // bit4
#8680 rx=0; // bit5
#8680 rx=0; // bit6
#8680 rx=0; // bit7
#8680 rx=1; // STOP
#8680 rx=1; // IDLE
#50 rx=0; // START
#8680 rx=0; // bit0 0xAA
#8680 rx=1; // bit1
#8680 rx=0; // bit2
#8680 rx=1; // bit3
#8680 rx=0; // bit4
#8680 rx=1; // bit5
#8680 rx=0; // bit6
#8680 rx=1; // bit7
#8680 rx=1; // STOP
#8680 rx=1; // IDLE
$display ("The current time is (%0d ns)", $time);
#100000 $finish; // Quit the simulation
end
uart_rx_cmd uart_rx_cmd_inst (
.clk(clk),
.rst_n(rst_n),
.uart_rx_pin(rx),
.cmd_recv(cmd_recv),
.rx_cmd_data(rx_cmd_data)
);
initial begin
$dumpfile("uart_rx_cmd.vcd");
$dumpvars(0, top_module);
end
endmodule
UART指令发送模块
发送模块逻辑稍微简单点。需要注意的是如果在数据包发送过程中又接收到发送请求会被忽略,必须等待上一次数据包全部发送完成才能进行下一次发送。
module uart_tx_cmd (
input clk,
input rst_n,
input uart_tx_en,
input [39:0] tx_cmd_data, // cmd data
output uart_tx_pin, // uart tx pin
output reg tx_status // uart work status
);
reg [39:0] cmd_data_temp;
reg [2:0] uart_baud_set;
reg [7:0] uart_tx_byte;
reg send_en;
wire uart_tx_status;
reg [1:0] tx_flow;
reg [3:0] tx_index;
wire uart_tx_done;
// uart tx
uart_tx uart_tx(
.clk(clk),
.rst_n(rst_n),
.tx_data(uart_tx_byte),
.send_en(send_en),
.baud_set(uart_baud_set),
.uart_tx(uart_tx_pin),
.tx_done(uart_tx_done),
.uart_state(uart_tx_status)
);
always @(posedge clk, negedge rst_n) begin
if (!rst_n) begin
uart_baud_set <= 3'd4; // 115200
end else begin
uart_baud_set <= uart_baud_set;
end
end
always @(posedge clk, negedge rst_n) begin
if (!rst_n) begin
tx_flow <= 2'b00;
send_en <= 1'b0;
tx_index <= 4'd0;
tx_status <= 1'b0;
end else begin
case (tx_flow)
2'd0: begin
send_en <= 1'b0;
if (uart_tx_en) begin
tx_status <= 1'b1;
tx_flow <= 2'd1;
cmd_data_temp <= tx_cmd_data;
tx_index <= 4'd0;
end else begin
tx_flow <= tx_flow;
tx_status <= 1'b0;
end
end
2'd1: begin
send_en <= 1'b1;
uart_tx_byte <= 8'h3C;
tx_flow <= 2'd2;
end
2'd2: begin // uart send status
if (uart_tx_done) begin
send_en <= 1'b1;
if (tx_index == 4'd0) begin
uart_tx_byte <= cmd_data_temp[7:0];
tx_index <= 4'd1;
end else if (tx_index == 4'd1) begin
uart_tx_byte <= cmd_data_temp[15:8];
tx_index <= 4'd2;
end else if (tx_index == 4'd2) begin
uart_tx_byte <= cmd_data_temp[23:16];
tx_index <= 4'd3;
end else if (tx_index == 4'd3) begin
uart_tx_byte <= cmd_data_temp[31:24];
tx_index <= 4'd4;
end else if (tx_index == 4'd4) begin
uart_tx_byte <= cmd_data_temp[39:32];
tx_index <= 4'd5;
end else if (tx_index == 4'd5) begin
uart_tx_byte <= 8'hAA;
tx_index <= 4'd6;
end else if (tx_index == 4'd6) begin
send_en <= 1'b0;
tx_index <= 4'd0;
tx_flow <= 2'd0;
end else begin
tx_flow <= 2'd0;
end
end else begin
send_en <= 1'b0;
tx_flow <= tx_flow;
end
end
2'd3: begin
send_en <= 1'b0;
tx_flow <= 2'd3;
tx_index <= 4'd0;
end
default: tx_flow <= 2'd0;
endcase
end
end
endmodule
仿真测试
# 编译,默认生成a.out文件
iverilog uart_tx_cmd_tb.v uart_tx.v uart_tx_cmd.v
# 生成vcd文件
vvp .\a.out
# 使用gtkwave软件打开波形图
gtkwave.exe .\uart_tx_cmd.vcd
`timescale 1ns/1ns
module top_module ();
reg clk=0;
always #10 clk = ~clk; // Create clock with period=20
// A testbench
reg rst_n=0;
reg uart_tx_en=0;
wire [7:0] rx_data;
wire uart_tx_pin;
reg [39:0] tx_cmd_data=40'h0504030201;
wire tx_status;
initial begin
#50 rst_n <= 1'b1;
#100 uart_tx_en=1;
#100 uart_tx_en=0;
$display ("The current time is (%0d ns)", $time);
#2000000 $finish; // Quit the simulation
end
uart_tx_cmd uart_tx_cmd_inst (
.clk(clk),
.rst_n(rst_n),
.uart_tx_en(uart_tx_en),
.tx_cmd_data(tx_cmd_data),
.uart_tx_pin(uart_tx_pin),
.tx_status(tx_status)
);
initial begin
$dumpfile("uart_tx_cmd.vcd");
$dumpvars(0, top_module);
end
endmodule
从仿真结果可以看出:数据从低位开始发送,最前面是自动添加的0x3C
,后面依次发送 0x01, 0x02, 0x03, 0x04, 0x05, 0x06
,符合设计要求
核心应用控制模块
将应用封装成一个独立的模块step_ctrl
仿真测试
# 编译,默认生成a.out文件
iverilog step_ctrl_tb.v step_ctrl.v key_debounce.v pwm.v rgb.v led_seg.v
# 生成vcd文件
vvp .\a.out
# 使用gtkwave软件打开vcd波形图
gtkwave.exe .\step_ctrl.vcd
模拟一次按键操作,RGB值发生变化,UART会发送数据
`timescale 1ns/1ns
module top_module ();
reg clk=0;
always #10 clk = ~clk; // Create clock with period=20
// A testbench
reg rst_n=0;
reg key_k1=1;
reg key_k2=1;
reg key_k3=1;
wire [2:0] sw_key=1;
wire [2:0] rgb1;
wire [2:0] rgb2;
wire [8:0] seg1_out_pins;
wire [8:0] seg2_out_pins;
reg uart_rx_cmd_status=0;
reg [39:0] uart_rx_cmd_data=0;
wire uart_tx_cmd_status;
wire [39:0] uart_tx_cmd_data;
wire [6:0] led;
initial begin
#50 rst_n <= 1'b1;
#50 key_k1=0;
#10000000 key_k1=1;
$display ("The current time is (%0d ns)", $time);
#5000000 $finish; // Quit the simulation
end
setp_ctrl setp_rgb_ctrl(
.clk(clk),
.rst_n(rst_n),
.key_k1(key_k1),
.key_k2(key_k2),
.key_k3(key_k3),
.sw_key(sw_key),
.led(led),
.rgb1(rgb1),
.rgb2(rgb2),
.seg1_out_pins(seg1_out_pins),
.seg2_out_pins(seg2_out_pins),
.uart_rx_cmd_status(uart_rx_cmd_status),
.uart_rx_cmd_data(uart_rx_cmd_data),
.uart_tx_cmd_status(uart_tx_cmd_status),
.uart_tx_cmd_data(uart_tx_cmd_data)
);
initial begin
$dumpfile("step_ctrl.vcd");
$dumpvars(0, top_module);
end
endmodule
从仿真可以看到,按键操作后生成了指令包。
资源使用情况
上位机设计
上位机比较简单,主要是通过串口收发数据。这里采用QT编写。
RGB三个通道可以单独调节控制,在FPGA上通过按键调节的值也可以实时同步到QT程序中。
RGB三种颜色混合值使用QLabel
控件显示了出来,可以和FPGA三色灯显示的颜色进行对比测试。
开源地址:https://gitee.com/bigstep/step-fpga-ctrl
注意:本项目依赖QT串口库 SerialBus
,需要提前下载安装好。
target_link_libraries(step-ctrl PRIVATE Qt${QT_VERSION_MAJOR}::SerialBus)
QT控制页面C++源代码
#include "mainwindow.h"
#include "./ui_mainwindow.h"
#include <QDateTime>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
m_r = 0;
m_g = 0;
m_b = 0;
QPalette lcdpat = ui->redNumber->palette();
lcdpat.setColor(QPalette::Normal, QPalette::WindowText, Qt::red);
ui->redNumber->setPalette(lcdpat);
lcdpat = ui->greenNumber->palette();
lcdpat.setColor(QPalette::Normal, QPalette::WindowText, Qt::green);
ui->greenNumber->setPalette(lcdpat);
lcdpat = ui->blueNumber->palette();
lcdpat.setColor(QPalette::Normal, QPalette::WindowText, Qt::blue);
ui->blueNumber->setPalette(lcdpat);
ui->rgbLabel->setStyleSheet("QLabel{background-color:rgb(0,0,0);}");
ui->horizontalSliderR->setEnabled(false);
ui->horizontalSliderG->setEnabled(false);
ui->horizontalSliderB->setEnabled(false);
serialStrList = m_serial.scanSerial();
for (int i = 0; i < serialStrList.size(); i++) {
ui->portComboBox->addItem(serialStrList[i]);
}
QFont font("Courier", 10);
ui->textEdit->setFont(font);
// 默认设置波特率为115200
ui->baudComboBox->setCurrentIndex(5);
connect(&m_serial, SIGNAL(readSignal()), this, SLOT(readSerialData()));
m_timer.setSingleShot(true);
connect(&m_timer, &QTimer::timeout, this, QOverload<>::of(&MainWindow::updateRGBToFPGA));
}
MainWindow::~MainWindow()
{
m_timer.stop();
}
void MainWindow::updateEditText(QString text) {
QString formattedDate = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss");
QString originStr = ui->textEdit->toPlainText();
originStr += formattedDate + " " + text + "\n";
ui->textEdit->clear();
ui->textEdit->setText(originStr);
ui->textEdit->moveCursor(QTextCursor::End);
}
// 读取从自定义串口类获得的数据
void MainWindow::readSerialData()
{
QByteArray recvArray = m_serial.getReadBuf();
qDebug() << "Serial RECV:" << recvArray.toHex();
updateEditText("-> " + recvArray.toHex());
if (recvArray.size() == 7 && recvArray[0] == 0x3C && (unsigned char)recvArray[6] == 0xAA) { // <
if (recvArray[1] == 0x71) { // RGB value update
m_r = recvArray[2];
m_g = recvArray[3];
m_b = recvArray[4];
// update UI value
ui->redNumber->display(m_r);
ui->greenNumber->display(m_g);
ui->blueNumber->display(m_b);
ui->horizontalSliderR->setValue(m_r);
ui->horizontalSliderG->setValue(m_g);
ui->horizontalSliderB->setValue(m_b);
QString style = QString("QLabel{background-color:rgb(%1,%2,%3);}").arg(m_r).arg(m_g).arg(m_b);
ui->rgbLabel->setStyleSheet(style);
}
}
m_serial.clearReadBuf(); // 读取完后,清空数据缓冲区
}
// 打开 关闭串口
void MainWindow::on_openPortButton_clicked()
{
if (ui->openPortButton->text() == tr("打开串口")) {
if (m_serial.open(ui->portComboBox->currentText(), ui->baudComboBox->currentText().toInt())) {
ui->portComboBox->setEnabled(false);
ui->baudComboBox->setEnabled(false);
ui->horizontalSliderR->setEnabled(true);
ui->horizontalSliderG->setEnabled(true);
ui->horizontalSliderB->setEnabled(true);
ui->openPortButton->setText(tr("关闭串口"));
updateEditText("串口打开成功");
} else {
QMessageBox::warning(this, "警告", "串口打开失败!");
updateEditText("串口打开失败");
}
} else {
m_serial.close();
ui->portComboBox->setEnabled(true);
ui->baudComboBox->setEnabled(true);
ui->horizontalSliderR->setEnabled(false);
ui->horizontalSliderG->setEnabled(false);
ui->horizontalSliderB->setEnabled(false);
ui->openPortButton->setText(tr("打开串口"));
}
}
void MainWindow::updateRGBToFPGA() {
QString style = QString("QLabel{background-color:rgb(%1,%2,%3);}").arg(m_r).arg(m_g).arg(m_b);
ui->rgbLabel->setStyleSheet(style);
QByteArray rgbArray;
rgbArray.resize(7);
rgbArray[0] = 0x3E; // >
rgbArray[1] = 0x01;
rgbArray[2] = m_r;
rgbArray[3] = m_g;
rgbArray[4] = m_b;
rgbArray[5] = 0x00;
rgbArray[6] = 0xAA;
updateEditText("<- " + rgbArray.toHex());
m_serial.sendData(rgbArray);
}
void MainWindow::on_horizontalSliderR_valueChanged(int value)
{
if (m_r == value) return;
m_r = (unsigned char)value;
m_timer.start(100);
ui->redNumber->display(value);
}
void MainWindow::on_horizontalSliderG_valueChanged(int value)
{
if (m_g == value) return;
m_g = (unsigned char)value;
m_timer.start(100);
ui->greenNumber->display(value);
}
void MainWindow::on_horizontalSliderB_valueChanged(int value)
{
if (m_b == value) return;
m_b = (unsigned char)value;
m_timer.start(100);
ui->blueNumber->display(value);
}
整体效果
两个RGB灯是相同的状态。
拨码开关需要拨到正确位置数码管和LED才能正确显示,按键才能操作。如果拨码开关设置错误,按键是无法操作的。
总结
第一次接触FPGA Verilog,之前都是做的软件方面的工作。
学习实践下来发现Verilog语法虽然不多,但是和软件编程还是有很大区别。Verilog写出的代码其实都是在描述电路,所以写代码的时候最好能清晰的知道具体的代码对应的电路,这个电路怎么运行的。而软件编程几乎都是顺序执行,和我们的大脑思考较为一致,相对比较容易容易。
有了这次的活动经验,知道FPGA的基本工作原理,以后的学习也有了方向。
最后非常感谢电子森林举办寒假练活动。以后有机会还要参加!