原文:
zh.annas-archive.org/md5/B28E444E77634E28D12AD6F4C3A426AD
译者:飞龙
第三部分:与其他工具和框架集成
在学习如何开发和测试嵌入式系统之后,您可以在本部分学习如何开发高级图形用户界面,以及如何为混合 FPGA/SoC 平台开发。
本节将涵盖以下章节:
第十章,使用 Qt 开发嵌入式系统
第十一章,为混合 SoC/FPGA 系统开发
第十章:使用 Qt 开发嵌入式系统
Qt(发音为 cute)是一个基于先进的 C++框架,涵盖了各种 API,允许您实现网络、图形用户界面、数据格式的解析、音频的播放和录制等。本章主要涵盖了 Qt 的图形方面,以及如何为嵌入式设备创建高级 GUI,为用户提供吸引人和功能齐全的 UI。
本章涵盖的主题如下:
-
使用 Qt 为嵌入式系统创建高级 GUI
-
使用 Qt 的 3D 设计师创建信息娱乐 UI
-
通过 GUI 扩展现有的嵌入式系统
正确框架的力量
框架本质上是一组旨在简化特定应用程序开发的代码集合。它为开发人员提供了一系列类或语言等效物,允许您实现应用程序逻辑,而无需担心与底层硬件的接口,或使用操作系统的 API。
在之前的章节中,我们使用了许多框架来简化开发工作,从 No date 框架(第四章,资源受限的嵌入式系统)和 CMSIS 到 Arduino 用于微控制器(MCUs),以及从低级 POCO 框架用于跨平台开发到更高级别的 Qt 框架。
每个框架都有特定类型的系统作为目标。对于 No date、CMSIS 和 Arduino,目标是从 8 位 AVR MCU 到 32 位 ARM MCU 的 MCUs。它们针对裸机系统,没有任何中间操作系统或类似的东西。在复杂性方面,我们还有包括完整操作系统的实时 OS 框架。
诸如 POCO 和 Qt 之类的框架通常针对各种操作系统,从桌面和服务器平台到 SoC 平台。在这里,它们主要作为操作系统特定 API 之间的抽象层,同时在这个抽象层之外提供额外的功能。这使您能够快速构建一个功能齐全的应用程序,而无需在每个功能上花费太多时间。
这对于网络功能特别重要,您不希望从头开始编写基于 TCP 套接字的服务器,而理想情况下只是想实例化一个现成的类并使用它。在 Qt 的情况下,它还提供了与图形用户界面相关的 API,以使跨平台 GUI 的开发更容易。其他提供这种功能的框架还包括 GTK+和 WxWidgets。然而,在本章中,我们将只关注使用 Qt 进行开发。
在第八章,示例-基于 Linux 的信息娱乐系统中,我们深入了解了如何使用 Qt 框架进行开发。在那里,我们大多忽略了图形用户界面(GUI)部分,尽管这可能是相对于其他基于操作系统的框架最有趣的部分。能够在多个操作系统上使用相同的 GUI 可能非常有用和方便。
这在大多数基于桌面的应用程序中都是如此,其中 GUI 是应用程序的关键部分,因此不必花费时间和精力在不同操作系统之间进行移植是一个重要的时间节省者。对于嵌入式平台,这也是真的,尽管在这里,您可以选择将其集成得比在桌面系统上更深入,正如我们将在下一刻看到的。
我们还将看一下您可以开发的各种类型的 Qt 应用程序,从简单的命令行界面(CLI)应用程序开始。
用于命令行的 Qt
尽管 Qt 框架的图形用户界面是一个重要的卖点,但也可以用它来开发仅限于命令行的应用程序。为此,我们只需使用QCoreApplication
类来创建输入和事件循环处理程序,就像这个例子中一样:
#include <QCoreApplication>
#include <core.h>
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
Core core;
connect(&core, &Core::done, &app, &app::quit, Qt::QueuedConnection);
core.start();
return app.exec();
}
在这里,我们的代码是在一个名为Core
的类中实现的。在主函数中,我们创建了一个QCoreApplication
实例,该实例接收命令行参数。然后我们实例化了我们类的一个实例。
我们将我们类的信号连接到QCoreApplication
实例,这样如果我们发出完成的信号,它将触发后者上的槽来清理和终止应用程序。
之后,我们调用我们类的方法来启动其功能,并最终通过在QCoreApplication
实例上调用exec()
来启动事件循环。在这一点上,我们可以使用信号。
请注意,这里也可以使用 Qt4 风格的连接语法,而不是之前的 Qt5 风格:
connect(core, SIGNAL(done()), &app, SLOT(quit()), Qt::QueuedConnection);
从功能上讲,这没有任何区别,对于大多数情况来说,使用任何一种都可以。
我们的类如下所示:
#include <QObject>
class Core : public QObject {
Q_OBJECT
public:
explicit Core(QObject *parent = 0);
signals:
void done();
public slots:
void start();
};
在 Qt-based 应用程序中,想要使用 Qt 的信号槽架构的每个类都需要派生自QObject
类,并在类声明中包含Q_OBJECT
宏。这对于 Qt 的qmake 预处理器
工具在应用程序代码被工具链编译之前知道要处理哪些类是必需的。
这是实现:
#include "core.h"
#include <iostream>
Core::Core(QObject *parent) : QObject(parent) {
//
}
void hang::start() {
std::cout << "Start emitting done()" << std::endl;
emit done();
}
值得注意的是,我们可以让任何 QObject 派生类的构造函数知道封装父类是什么,从而允许父类拥有这些子类,并在自身被销毁时调用它们的析构函数。
基于 GUI 的 Qt 应用程序
回到第八章中基于 Qt 的示例项目,示例-Linux 基础信息娱乐系统,我们现在可以比较其主函数和之前的仅限命令行版本,看看在向项目添加 GUI 后会发生什么变化:
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
这里最明显的变化是我们使用了QApplication
而不是QCoreApplication
。另一个重大变化是我们不再使用完全自定义的类,而是从QMainWindow
派生的类:
#include <QMainWindow>
#include <QAudioRecorder>
#include <QAudioProbe>
#include <QMediaPlayer>
namespace Ui {
class MainWindow;
}
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
public slots:
void playBluetooth();
void stopBluetooth();
void playOnlineStream();
void stopOnlineStream();
void playLocalFile();
void stopLocalFile();
void recordMessage();
void playMessage();
void errorString(QString err);
void quit();
private:
Ui::MainWindow *ui;
QMediaPlayer* player;
QAudioRecorder* audioRecorder;
QAudioProbe* audioProbe;
qint64 silence;
private slots:
void processBuffer(QAudioBuffer);
};
在这里,我们可以看到MainWindow
类确实是从QMainWindow
派生出来的,这也赋予了它show()
方法。值得注意的是MainWindow
实例在 UI 命名空间中声明。这与我们在运行 qmake 工具时生成的自动生成的代码相关联,我们马上就会看到。接下来是构造函数:
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent),
ui(new Ui::MainWindow) {
ui->setupUi(this);
这里需要注意的第一件事是我们如何从 UI 描述文件中填充 GUI。这个文件通常是通过使用 Qt Creator IDE 中的 Qt Designer 工具直观地布局 GUI 而创建的。这个 UI 文件包含了每个小部件的属性描述,以及应用于它们的布局等等。
当然,也可以以编程方式创建这些小部件并将它们添加到布局中。然而,对于更大的布局来说,这变得相当乏味。通常,您为主窗口创建一个单独的 UI 文件,并为每个子窗口和对话框创建一个额外的 UI 文件。然后可以以类似的方式将它们填充到窗口或对话框中。
connect(ui->actionQuit, SIGNAL(triggered()), this, SLOT(quit()));
GUI 中的菜单操作通过指定菜单操作(QAction
实例)上的特定信号与内部槽相连接。我们可以在这里看到它们在ui
对象中,这个对象在 UI 文件的自动生成源代码中可以找到,正如我们之前提到的:
connect(ui->playBluetoothButton, SIGNAL(pressed), this, SLOT(playBluetooth));
connect(ui->stopBluetoothButton, SIGNAL(pressed), this, SLOT(stopBluetooth));
connect(ui->playLocalAudioButton, SIGNAL(pressed), this, SLOT(playLocalFile));
connect(ui->stopLocalAudioButton, SIGNAL(pressed), this, SLOT(stopLocalFile));
connect(ui->playOnlineStreamButton, SIGNAL(pressed), this, SLOT(playOnlineStream));
connect(ui->stopOnlineStreamButton, SIGNAL(pressed), this, SLOT(stopOnlineStream));
connect(ui->recordMessageButton, SIGNAL(pressed), this, SLOT(recordMessage));
connect(ui->playBackMessage, SIGNAL(pressed), this, SLOT(playMessage));
GUI 中的按钮小部件以类似的方式连接,尽管它们当然会因为它们是不同类型的小部件而发出不同的信号:
silence = 0;
// Create the audio interface instances.
player = new QMediaPlayer(this);
audioRecorder = new QAudioRecorder(this);
audioProbe = new QAudioProbe(this);
// Configure the audio recorder.
QAudioEncoderSettings audioSettings;
audioSettings.setCodec("audio/amr");
audioSettings.setQuality(QMultimedia::HighQuality);
audioRecorder->setEncodingSettings(audioSettings);
audioRecorder->setOutputLocation(QUrl::fromLocalFile("message/last_message.amr"));
// Configure audio probe.
connect(audioProbe, SIGNAL(audioBufferProbed(QAudioBuffer)), this, SLOT(processBuffer(QAudioBuffer)));
audioProbe→setSource(audioRecorder);
我们可以在这里做任何其他构造函数中会做的事情,包括设置默认值和创建我们以后需要的类的实例:
QThread* thread = new QThread;
VoiceInput* vi = new VoiceInput();
vi->moveToThread(thread);
connect(thread, SIGNAL(started()), vi, SLOT(run()));
connect(vi, SIGNAL(finished()), thread, SLOT(quit()));
connect(vi, SIGNAL(finished()), vi, SLOT(deleteLater()));
connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater()));
connect(vi, SIGNAL(error(QString)), this, SLOT(errorString(QString)));
connect(vi, SIGNAL(playBluetooth), this, SLOT(playBluetooth));
connect(vi, SIGNAL(stopBluetooth), this, SLOT(stopBluetooth));
connect(vi, SIGNAL(playLocal), this, SLOT(playLocalFile));
connect(vi, SIGNAL(stopLocal), this, SLOT(stopLocalFile));
connect(vi, SIGNAL(playRemote), this, SLOT(playOnlineStream));
connect(vi, SIGNAL(stopRemote), this, SLOT(stopOnlineStream));
connect(vi, SIGNAL(recordMessage), this, SLOT(recordMessage));
connect(vi, SIGNAL(playMessage), this, SLOT(playMessage));
thread->start();
}
这里需要记住的一件关键的事情是这个类在 UI 线程上运行,这意味着我们不应该在这里做任何繁重的工作。这就是为什么我们将这样的类实例移到它们自己的线程中,就像这样:
MainWindow::~MainWindow() {
delete ui;
}
在构造函数中,我们删除 UI 和所有相关元素。
嵌入式 Qt
Qt 框架的一个主要目标是桌面系统之外的嵌入式系统,特别是嵌入式 Linux,在那里有几种不同的使用 Q 的方式。嵌入式 Qt 的主要目的是通过允许您直接启动到优化的 Qt 环境中来优化软件库,并允许多种方式渲染到显示器。
Qt for Embedded Linux 支持以下用于渲染的平台插件:
插件 | 描述 |
---|---|
EGLFS | 提供对 OpenGL ES 或类似的 3D 渲染 API 的接口。通常是嵌入式 Linux 的默认配置。有关 EGL 的更多详细信息,请访问以下网址:www.khronos.org/egl . |
LinuxFB | 通过 Linux 的 fbdev 子系统直接写入帧缓冲。仅支持软件渲染内容。因此,在某些设置上,显示性能可能会受到限制。 |
DirectFB | 使用 DirectFB 库直接写入图形卡的帧缓冲。 |
Wayland | 使用 Wayland 窗口系统。这允许多个并发窗口,但当然对硬件要求更高。 |
除此之外,Qt for Embedded Linux 还配备了各种 API,用于处理触摸和笔输入等。为了优化基于 Qt 的应用程序的系统,通常会删除任何不相关的服务、进程和库,从而使系统在几秒钟内启动到嵌入式应用程序中。
使用样式表的自定义 GUI
桌面系统通常使用的标准基于小部件的 GUI 不太容易定制。因此,通常要么需要覆盖QWidget
实例中的绘图函数并处理小部件绘制的每个像素,要么使用基于样式表的定制。
Qt 样式表允许您动态地调整单个小部件的外观和感觉。它们基本上是使用与 HTML 页面一样的层叠样式表(CSS)语法编写的。它们允许您更改小部件的元素,如边框、圆角、或元素的厚度和颜色。
QML
Qt 建模语言(QML)是一种用户界面标记语言。它基于 JavaScript,并且甚至使用内联 JavaScript。它可以用于创建动态和完全定制的用户界面,并通常与 Qt Quick 模块一起使用。
在本章的后面,我们将深入研究如何创建动态 GUI。
3D 设计师
使用 Qt 5 引入了 Qt 3D 模块,它简化了对 OpenGL 渲染 API 的访问。这个新模块被用作 Qt 3D Designer 编辑器和相关运行时的基础。它可以用于创建高度动态的 GUI,具有 2D 和 3D 元素的组合。
它与手工制作的基于 QML 的 GUI 非常相似,但提供了更简化的工作流程,易于添加动画,并预览项目。它类似于 Qt Designer Studio,后者更专注于 2D GUI,但需要购买许可证,不免费提供。
向信息娱乐系统添加 GUI 的示例
在这个例子中,我们将使用 C++、Qt 和 QML 来创建一个图形用户界面,能够显示当前播放的音轨,执行音频可视化,指示播放进度,并允许您使用屏幕按钮切换不同的输入模式。
这个例子是基于 Qt 文档中的音频可视化器示例。它可以在 Qt 安装文件夹中找到(如果安装了示例),也可以在 Qt 网站上找到:doc.qt.io/qt-5/qt3d-audio-visualizer-qml-example.html.
这段代码与官方示例的主要区别在于,QMediaPlayer
媒体播放器被移入了 C++代码中,还有其他一些函数。而在新的QmlInterface
类中,QML UI 和 C++后端之间使用了一些信号和槽来处理按钮按下、更新 UI 和与媒体播放器的交互。
这样的 GUI 可以连接到现有的信息娱乐项目代码中,以控制其功能,使用 GUI 以及语音驱动界面。
在这个示例中,我们组合的 GUI 在操作时看起来是这样的:
主要
主要源文件如下所示:
#include "interface.h"
#include <QtGui/QGuiApplication>
#include <QtGui/QOpenGLContext>
#include <QtQuick/QQuickView>
#include <QtQuick/QQuickItem>
#include <QtQml/QQmlContext>
#include <QObject>
int main(int argc, char* argv[]) {
QGuiApplication app(argc, argv);
QSurfaceFormat format;
if (QOpenGLContext::openGLModuleType() == QOpenGLContext::LibGL) {
format.setVersion(3, 2);
format.setProfile(QSurfaceFormat::CoreProfile);
}
format.setDepthBufferSize(24);
format.setStencilBufferSize(8);
QQuickView view;
view.setFormat(format);
view.create();
QmlInterface qmlinterface;
view.rootContext()->setContextProperty("qmlinterface", &qmlinterface);
view.setSource(QUrl("qrc:/main.qml"));
qmlinterface.setPlaying();
view.setResizeMode(QQuickView::SizeRootObjectToView);
view.setMaximumSize(QSize(1820, 1080));
view.setMinimumSize(QSize(300, 150));
view.show();
return app.exec();
}
我们的自定义类被添加到 QML 查看器(QQuickView
)作为上下文类。这充当了 QML UI 和我们的 C++代码之间的代理,我们马上就会看到。查看器本身使用 OpenGL 表面来渲染 UI。
QmlInterface
我们自定义类的头部包含了许多添加,以使属性和方法对 QML 代码可见:
#include <QtCore/QObject>
#include <QMediaPlayer>
#include <QByteArray>
class QmlInterface : public QObject {
Q_OBJECT
Q_PROPERTY(QString durationTotal READ getDurationTotal NOTIFY durationTotalChanged)
Q_PROPERTY(QString durationLeft READ getDurationLeft NOTIFY durationLeftChanged)
Q_PROPERTY
标签告诉 qmake 解析器,这个类包含一个属性(变量),应该对 QML 代码可见,参数指定变量的名称,用于读取和写入变量的方法(如果需要),最后是每当属性发生变化时发出的信号。
这允许设置自动更新功能,以保持此属性在 C++代码和 QML 端之间同步:
QString formatDuration(qint64 milliseconds);
QMediaPlayer mediaPlayer;
QByteArray magnitudeArray;
const int millisecondsPerBar = 68;
QString durationTotal;
QString durationLeft;
qint64 trackDuration;
public:
explicit QmlInterface(QObject *parent = nullptr);
Q_INVOKABLE bool isHoverEnabled() const;
Q_INVOKABLE void setPlaying();
Q_INVOKABLE void setStopped();
Q_INVOKABLE void setPaused();
Q_INVOKABLE qint64 duration();
Q_INVOKABLE qint64 position();
Q_INVOKABLE double getNextAudioLevel(int offsetMs);
QString getDurationTotal() { return durationTotal; }
QString getDurationLeft() { return durationLeft; }
public slots:
void mediaStatusChanged(QMediaPlayer::MediaStatus status);
void durationChanged(qint64 duration);
void positionChanged(qint64 position);
signals:
void start();
void stopped();
void paused();
void playing();
void durationTotalChanged();
void durationLeftChanged();
};
同样,Q_INVOKABLE
标签确保这些方法对 QML 端可见,并且可以从那里调用。
这是实现:
#include "interface.h"
#include <QtGui/QTouchDevice>
#include <QDebug>
#include <QFile>
#include <QtMath>
QmlInterface::QmlInterface(QObject *parent) : QObject(parent) {
// Set track for media player.
mediaPlayer.setMedia(QUrl("qrc:/music/tiltshifted_lost_neon_sun.mp3"));
// Load magnitude file for the audio track.
QFile magFile(":/music/visualization.raw", this);
magFile.open(QFile::ReadOnly);
magnitudeArray = magFile.readAll();
// Media player connections.
connect(&mediaPlayer, SIGNAL(mediaStatusChanged(QMediaPlayer::MediaStatus)), this, SLOT(mediaStatusChanged(QMediaPlayer::MediaStatus)));
connect(&mediaPlayer, SIGNAL(durationChanged(qint64)), this, SLOT(durationChanged(qint64)));
connect(&mediaPlayer, SIGNAL(positionChanged(qint64)), this, SLOT(positionChanged(qint64)));
}
构造函数与原始示例项目有很大不同,这里创建了媒体播放器实例及其连接。
我们在这里加载了与原始项目中使用的相同音乐文件。将代码集成到信息娱乐项目或类似项目中时,您可以使其动态化。同样,我们在这里加载的用于获取音乐文件振幅的文件在完全集成时可能会被省略,而选择动态生成振幅值:
bool QmlInterface::isHoverEnabled() const {
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID) || defined(Q_OS_QNX) || defined(Q_OS_WINRT)
return false;
#else
bool isTouch = false;
foreach (const QTouchDevice *dev, QTouchDevice::devices()) {
if (dev->type() == QTouchDevice::TouchScreen) {
isTouch = true;
break;
}
}
bool isMobile = false;
if (qEnvironmentVariableIsSet("QT_QUICK_CONTROLS_MOBILE")) {
isMobile = true;
}
return !isTouch && !isMobile;
#endif
}
这是以前存在于 QML 上下文类中的唯一方法。它用于检测代码是否在具有触摸屏的移动设备上运行:
void QmlInterface::setPlaying() {
mediaPlayer.play();
}
void QmlInterface::setStopped() {
mediaPlayer.stop();
}
void QmlInterface::setPaused() {
mediaPlayer.pause();
}
我们有许多控制方法,连接到 UI 中的按钮,以允许控制媒体播放器实例:
void QmlInterface::mediaStatusChanged(QMediaPlayer::MediaStatus status) {
if (status == QMediaPlayer::EndOfMedia) {
emit stopped();
}
}
这个槽方法用于检测媒体播放器是否已经到达了活动曲目的结尾,以便 UI 可以被通知应该更新以指示这一点:
void QmlInterface::durationChanged(qint64 duration) {
qDebug() << "Duration changed: " << duration;
durationTotal = formatDuration(duration);
durationLeft = "-" + durationTotal;
trackDuration = duration;
emit start();
emit durationTotalChanged();
emit durationLeftChanged();
}
void QmlInterface::positionChanged(qint64 position) {
qDebug() << "Position changed: " << position;
durationLeft = "-" + formatDuration((trackDuration - position));
emit durationLeftChanged();
}
这两个槽方法连接到媒体播放器实例。持续时间槽是必需的,因为新加载的曲目的长度(持续时间)不会立即可用。相反,它是一个异步更新的属性。
因此,我们必须等到媒体播放器完成并发出信号,表明它已经完成了这个过程。
接下来,为了让我们能够更新当前曲目的剩余时间,我们还会不断地从媒体播放器获取当前位置的更新,这样我们就可以用新值更新 UI。
持续时间和位置属性都使用了我们在这个类的头文件描述中看到的链接方法在 UI 中进行更新。
最后,我们发出一个start()
信号,它与 QML 代码中的一个槽连接,将启动可视化过程,我们稍后会在 QML 代码中看到:
qint64 QmlInterface::duration() {
qDebug() << "Returning duration value: " << mediaPlayer.duration();
return mediaPlayer.duration();
}
qint64 QmlInterface::position() {
qDebug() << "Returning position value: " << mediaPlayer.position();
return mediaPlayer.position();
}
持续时间属性也被可视化代码使用。在这里,我们允许直接获取它。同样,我们也使位置属性可用,可以直接调用:
double QmlInterface::getNextAudioLevel(int offsetMs) {
// Calculate the integer index position in to the magnitude array
qint64 index = ((mediaPlayer.position() + offsetMs) / millisecondsPerBar) | 0;
if (index < 0 || index >= (magnitudeArray.length() / 2)) {
return 0.0;
}
return (((quint16*) magnitudeArray.data())[index] / 63274.0);
}
这种方法是从原始项目的 JavaScript 代码移植过来的,执行的是根据之前从文件中读取的振幅数据来确定音频级别的相同任务:
QString QmlInterface::formatDuration(qint64 milliseconds) {
qint64 minutes = floor(milliseconds / 60000);
milliseconds -= minutes * 60000;
qint64 seconds = milliseconds / 1000;
seconds = round(seconds);
if (seconds < 10) {
return QString::number(minutes) + ":0" + QString::number(seconds);
}
else {
return QString::number(minutes) + ":" + QString::number(seconds);
}
}
同样,这个方法也是从原始项目的 JavaScript 代码移植过来的,因为我们将依赖于它的代码移入了 C++代码中。它接受曲目持续时间或位置的毫秒计数,并将其转换为包含分钟和秒的字符串,与原始值匹配。
QML
接下来,我们已经完成了 C++端的工作,现在可以看一下 QML UI 了。
首先,这是主要的 QML 文件:
import QtQuick 2.0
import QtQuick.Scene3D 2.0
import QtQuick.Layouts 1.2
import QtMultimedia 5.0
Item {
id: mainview
width: 1215
height: 720
visible: true
property bool isHoverEnabled: false
property int mediaLatencyOffset: 68
QML 文件由一系列元素组成。在这里,我们定义了顶层元素,给它指定了尺寸和名称:
state: "stopped"
states: [
State {
name: "playing"
PropertyChanges {
target: playButtonImage
source: {
if (playButtonMouseArea.containsMouse)
"qrc:/images/pausehoverpressed.png"
else
"qrc:/images/pausenormal.png"
}
}
PropertyChanges {
target: stopButtonImage
source: "qrc:/images/stopnormal.png"
}
},
State {
name: "paused"
PropertyChanges {
target: playButtonImage
source: {
if (playButtonMouseArea.containsMouse)
"qrc:/images/playhoverpressed.png"
else
"qrc:/images/playnormal.png"
}
}
PropertyChanges {
target: stopButtonImage
source: "qrc:/images/stopnormal.png"
}
},
State {
name: "stopped"
PropertyChanges {
target: playButtonImage
source: "qrc:/images/playnormal.png"
}
PropertyChanges {
target: stopButtonImage
source: "qrc:/images/stopdisabled.png"
}
}
]
定义了 UI 的一些状态,以及应该触发的变化:
Connections {
target: qmlinterface
onStopped: mainview.state = "stopped"
onPaused: mainview.state = "paused"
onPlaying: mainview.state = "started"
onStart: visualizer.startVisualization()
}
这些是将 C++端的信号链接到本地处理程序的连接。我们将我们的自定义类作为这些信号的源,然后为我们希望处理的每个信号定义处理程序,通过为其添加前缀并添加应该执行的代码。
在这里,我们看到启动信号与一个处理程序链接,触发可视化模块中启动该模块的函数:
Component.onCompleted: isHoverEnabled = qmlinterface.isHoverEnabled()
Image {
id: coverImage
anchors.fill: parent
source: "qrc:/images/albumcover.png"
}
这个Image
元素定义了背景图像,我们从构建项目时添加到可执行文件中的资源中加载:
Scene3D {
anchors.fill: parent
Visualizer {
id: visualizer
animationState: mainview.state
numberOfBars: 120
barRotationTimeMs: 8160 // 68 ms per bar
}
}
3D 场景将填充可视化器的内容:
Rectangle {
id: blackBottomRect
color: "black"
width: parent.width
height: 0.14 * mainview.height
anchors.bottom: parent.bottom
}
Text {
text: qmlinterface.durationTotal
color: "#80C342"
x: parent.width / 6
y: mainview.height - mainview.height / 8
font.pixelSize: 12
}
Text {
text: qmlinterface.durationLeft
color: "#80C342"
x: parent.width - parent.width / 6
y: mainview.height - mainview.height / 8
font.pixelSize: 12
}
这两个文本元素与我们自定义的 C++类中的属性相关联,就像我们之前看到的那样。这些值将随着 C++类实例中的值的变化而保持更新:
property int buttonHorizontalMargin: 10
Rectangle {
id: playButton
height: 54
width: 54
anchors.bottom: parent.bottom
anchors.bottomMargin: width
x: parent.width / 2 - width - buttonHorizontalMargin
color: "transparent"
Image {
id: playButtonImage
source: "qrc:/images/pausenormal.png"
}
MouseArea {
id: playButtonMouseArea
anchors.fill: parent
hoverEnabled: isHoverEnabled
onClicked: {
if (mainview.state == 'paused' || mainview.state == 'stopped')
mainview.state = 'playing'
else
mainview.state = 'paused'
}
onEntered: {
if (mainview.state == 'playing')
playButtonImage.source = "qrc:/images/pausehoverpressed.png"
else
playButtonImage.source = "qrc:/images/playhoverpressed.png"
}
onExited: {
if (mainview.state == 'playing')
playButtonImage.source = "qrc:/images/pausenormal.png"
else
playButtonImage.source = "qrc:/images/playnormal.png"
}
}
}
Rectangle {
id: stopButton
height: 54
width: 54
anchors.bottom: parent.bottom
anchors.bottomMargin: width
x: parent.width / 2 + buttonHorizontalMargin
color: "transparent"
Image {
id: stopButtonImage
source: "qrc:/images/stopnormal.png"
}
MouseArea {
anchors.fill: parent
hoverEnabled: isHoverEnabled
onClicked: mainview.state = 'stopped'
onEntered: {
if (mainview.state != 'stopped')
stopButtonImage.source = "qrc:/images/stophoverpressed.png"
}
onExited: {
if (mainview.state != 'stopped')
stopButtonImage.source = "qrc:/images/stopnormal.png"
}
}
}
}
源代码的其余部分用于设置用于控制播放的各个按钮,包括播放、停止和暂停按钮,根据需要进行切换。
接下来,我们将看一下振幅条文件:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
import QtQuick 2.4 as QQ2
Entity {
property int rotationTimeMs: 0
property int entityIndex: 0
property int entityCount: 0
property int startAngle: 0 + 360 / entityCount * entityIndex
property bool needsNewMagnitude: true
property real magnitude: 0
property real animWeight: 0
property color lowColor: "black"
property color highColor: "#b3b3b3"
property color barColor: lowColor
property string entityAnimationsState: "stopped"
property bool entityAnimationsPlaying: true
property var entityMesh: null
在我们深入到动画状态变化处理程序之前,定义了一些属性:
onEntityAnimationsStateChanged: {
if (animationState == "paused") {
if (angleAnimation.running)
angleAnimation.pause()
if (barColorAnimations.running)
barColorAnimations.pause()
} else if (animationState == "playing"){
needsNewMagnitude = true;
if (heightDecreaseAnimation.running)
heightDecreaseAnimation.stop()
if (angleAnimation.paused) {
angleAnimation.resume()
} else if (!entityAnimationsPlaying) {
magnitude = 0
angleAnimation.start()
entityAnimationsPlaying = true
}
if (barColorAnimations.paused)
barColorAnimations.resume()
} else {
if (animWeight != 0)
heightDecreaseAnimation.start()
needsNewMagnitude = true
angleAnimation.stop()
barColorAnimations.stop()
entityAnimationsPlaying = false
}
}
每当音频播放停止、暂停或开始时,动画都必须更新以匹配这种状态变化:
property Material barMaterial: PhongMaterial {
diffuse: barColor
ambient: Qt.darker(barColor)
specular: "black"
shininess: 1
}
这定义了振幅条的外观,使用 Phong 着色:
property Transform angleTransform: Transform {
property real heightIncrease: magnitude * animWeight
property real barAngle: startAngle
matrix: {
var m = Qt.matrix4x4()
m.rotate(barAngle, Qt.vector3d(0, 1, 0))
m.translate(Qt.vector3d(1.1, heightIncrease / 2 - heightIncrease * 0.05, 0))
m.scale(Qt.vector3d(0.5, heightIncrease * 15, 0.5))
return m;
}
property real compareAngle: barAngle
onBarAngleChanged: {
compareAngle = barAngle
if (compareAngle > 360)
compareAngle = barAngle - 360
if (compareAngle > 180) {
parent.enabled = false
animWeight = 0
if (needsNewMagnitude) {
// Calculate the ms offset where the bar will be at the center point of the
// visualization and fetch the correct magnitude for that point in time.
var offset = (90.0 + 360.0 - compareAngle) * (rotationTimeMs / 360.0)
magnitude = qmlinterface.getNextAudioLevel(offset)
needsNewMagnitude = false
}
} else {
parent.enabled = true
// Calculate a power of 2 curve for the bar animation that peaks at 90 degrees
animWeight = Math.min((compareAngle / 90), (180 - compareAngle) / 90)
animWeight = animWeight * animWeight
if (!needsNewMagnitude) {
needsNewMagnitude = true
barColorAnimations.start()
}
}
}
}
当振幅条在屏幕上移动时,它们相对于摄像机的位置会发生变化,因此我们需要不断计算新的角度和显示高度。
在这一部分,我们还用我们的 C++类中的新方法调用替换了原始的音频级别方法的调用:
components: [entityMesh, barMaterial, angleTransform]
QQ2.NumberAnimation {
id: angleAnimation
target: angleTransform
property: "barAngle"
duration: rotationTimeMs
loops: QQ2.Animation.Infinite
running: true
from: startAngle
to: 360 + startAngle
}
QQ2.NumberAnimation {
id: heightDecreaseAnimation
target: angleTransform
property: "heightIncrease"
duration: 400
running: false
from: angleTransform.heightIncrease
to: 0
onStopped: barColor = lowColor
}
property int animationDuration: angleAnimation.duration / 6
QQ2.SequentialAnimation on barColor {
id: barColorAnimations
running: false
QQ2.ColorAnimation {
from: lowColor
to: highColor
duration: animationDuration
}
QQ2.PauseAnimation {
duration: animationDuration
}
QQ2.ColorAnimation {
from: highColor
to: lowColor
duration: animationDuration
}
}
}
文件的其余部分包含了一些动画变换。
最后,这是可视化模块:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
import QtQuick 2.2 as QQ2
Entity {
id: sceneRoot
property int barRotationTimeMs: 1
property int numberOfBars: 1
property string animationState: "stopped"
property real titleStartAngle: 95
property real titleStopAngle: 5
onAnimationStateChanged: {
if (animationState == "playing") {
qmlinterface.setPlaying();
if (progressTransformAnimation.paused)
progressTransformAnimation.resume()
else
progressTransformAnimation.start()
} else if (animationState == "paused") {
qmlinterface.setPaused();
if (progressTransformAnimation.running)
progressTransformAnimation.pause()
} else {
qmlinterface.setStopped();
progressTransformAnimation.stop()
progressTransform.progressAngle = progressTransform.defaultStartAngle
}
}
这一部分从与本地媒体播放器实例的交互改为与 C++代码中的新实例交互。除此之外,我们没有做任何改动。这是主要的处理程序,用于处理由用户交互引起的场景变化,或者曲目的开始或结束:
QQ2.Item {
id: stateItem
state: animationState
states: [
QQ2.State {
name: "playing"
QQ2.PropertyChanges {
target: titlePrism
titleAngle: titleStopAngle
}
},
QQ2.State {
name: "paused"
QQ2.PropertyChanges {
target: titlePrism
titleAngle: titleStopAngle
}
},
QQ2.State {
name: "stopped"
QQ2.PropertyChanges {
target: titlePrism
titleAngle: titleStartAngle
}
}
]
transitions: QQ2.Transition {
QQ2.NumberAnimation {
property: "titleAngle"
duration: 2000
running: false
}
}
}
为曲目标题对象定义了一些属性变化和转换:
function startVisualization() {
progressTransformAnimation.duration = qmlinterface.duration()
mainview.state = "playing"
progressTransformAnimation.start()
}
这个函数是启动整个可视化序列的方法。它使用我们的 C++类实例获取的曲目持续时间来确定曲目播放动画的进度条尺寸,然后开始可视化动画:
Camera {
id: camera
projectionType: CameraLens.PerspectiveProjection
fieldOfView: 45
aspectRatio: 1820 / 1080
nearPlane: 0.1
farPlane: 1000.0
position: Qt.vector3d(0.014, 0.956, 2.178)
upVector: Qt.vector3d(0.0, 1.0, 0.0)
viewCenter: Qt.vector3d(0.0, 0.7, 0.0)
}
为 3D 场景定义了一个摄像机:
Entity {
components: [
DirectionalLight {
intensity: 0.9
worldDirection: Qt.vector3d(0, 0.6, -1)
}
]
}
RenderSettings {
id: external_forward_renderer
activeFrameGraph: ForwardRenderer {
camera: camera
clearColor: "transparent"
}
}
为场景创建了渲染器和光源:
components: [external_forward_renderer]
CuboidMesh {
id: barMesh
xExtent: 0.1
yExtent: 0.1
zExtent: 0.1
}
为振幅条创建了一个网格:
NodeInstantiator {
id: collection
property int maxCount: parent.numberOfBars
model: maxCount
delegate: BarEntity {
id: cubicEntity
entityMesh: barMesh
rotationTimeMs: sceneRoot.barRotationTimeMs
entityIndex: index
entityCount: sceneRoot.numberOfBars
entityAnimationsState: animationState
magnitude: 0
}
}
定义了条的数量以及其他属性:
Entity {
id: titlePrism
property real titleAngle: titleStartAngle
Entity {
id: titlePlane
PlaneMesh {
id: titlePlaneMesh
width: 550
height: 100
}
Transform {
id: titlePlaneTransform
scale: 0.003
translation: Qt.vector3d(0, 0.11, 0)
}
NormalDiffuseMapAlphaMaterial {
id: titlePlaneMaterial
diffuse: TextureLoader { source: "qrc:/images/demotitle.png" }
normal: TextureLoader { source: "qrc:/images/normalmap.png" }
shininess: 1.0
}
components: [titlePlaneMesh, titlePlaneMaterial, titlePlaneTransform]
}
这个平面包含了没有曲目播放时的标题对象:
Entity {
id: songTitlePlane
PlaneMesh {
id: songPlaneMesh
width: 550
height: 100
}
Transform {
id: songPlaneTransform
scale: 0.003
rotationX: 90
translation: Qt.vector3d(0, -0.03, 0.13)
}
property Material songPlaneMaterial: NormalDiffuseMapAlphaMaterial {
diffuse: TextureLoader { source: "qrc:/images/songtitle.png" }
normal: TextureLoader { source: "qrc:/images/normalmap.png" }
shininess: 1.0
}
components: [songPlaneMesh, songPlaneMaterial, songPlaneTransform]
}
这个平面包含了曲目激活时的歌曲标题:
property Transform titlePrismPlaneTransform: Transform {
matrix: {
var m = Qt.matrix4x4()
m.translate(Qt.vector3d(-0.5, 1.3, -0.4))
m.rotate(titlePrism.titleAngle, Qt.vector3d(1, 0, 0))
return m;
}
}
components: [titlePlane, songTitlePlane, titlePrismPlaneTransform]
}
为了在播放和非播放转换之间转换平面,使用了这个变换:
Mesh {
id: circleMesh
source: "qrc:/meshes/circle.obj"
}
Entity {
id: circleEntity
property Material circleMaterial: PhongAlphaMaterial {
alpha: 0.4
ambient: "black"
diffuse: "black"
specular: "black"
shininess: 10000
}
components: [circleMesh, circleMaterial]
}
添加了一个提供反射效果的圆形网格:
Mesh {
id: progressMesh
source: "qrc:/meshes/progressbar.obj"
}
Transform {
id: progressTransform
property real defaultStartAngle: -90
property real progressAngle: defaultStartAngle
rotationY: progressAngle
}
Entity {
property Material progressMaterial: PhongMaterial {
ambient: "purple"
diffuse: "white"
}
components: [progressMesh, progressMaterial, progressTransform]
}
QQ2.NumberAnimation {
id: progressTransformAnimation
target: progressTransform
property: "progressAngle"
duration: 0
running: false
from: progressTransform.defaultStartAngle
to: -270
onStopped: if (animationState != "stopped") animationState = "stopped"
}
}
最后,这个网格创建了进度条,它从左到右移动以指示播放进度。
整个项目通过运行 qmake 然后 make 来编译,或者通过在 Qt Creator 中打开项目并从那里构建来编译。运行时,它将自动开始播放包含的歌曲并显示振幅可视化,同时可以通过 UI 中的按钮进行控制。
总结
在本章中,我们看了 Qt 框架在开发嵌入式系统方面的多种用途。我们简要地比较了它与其他框架的区别,以及 Qt 如何针对这些嵌入式平台进行优化,然后通过一个基于 QML 的 GUI 示例来演示如何将其添加到我们之前创建的信息娱乐系统中。
您现在应该能够创建基本的 Qt 应用程序,包括纯粹基于命令行的应用程序和带有图形用户界面的应用程序。您还应该清楚地了解 Qt 提供的开发 GUI 的各种选项。
在下一章中,我们将看看嵌入式平台的下一个演进,使用可编程门阵列(FPGAs)来为嵌入式平台加入定制的基于硬件的功能,以加快其速度。
第十一章:为混合 SoC/FPGA 系统开发
除了标准的基于 CPU 的嵌入式系统之外,一个越来越常见的方法是将 CPU 与现场可编程门阵列(FGPAs)结合在 SoC 的形式中。这使得 CPU 密集型算法和处理,包括 DSP 和图像处理,可以在系统的 FPGA 部分上实现,而 CPU 端处理较不密集的任务,如用户交互、存储和网络。
本章中,我们将涵盖以下主题:
-
如何与混合 FPGA/SoC 系统的 FPGA 端进行通信
-
学习如何在 FPGA 中实现各种算法,并从 SoC 端使用
-
如何在混合 FPGA/SoC 系统上实现基本示波器
极端并行化
在性能方面,使用单核处理器一次执行单个指令基本上是实现算法或其他功能的最慢方式。从这里开始,您可以将这种单一执行流扩展到多个流,使用单个处理器核心的各个功能单元进行同时调度。
提高性能的下一步是增加更多的核心,这当然会使调度变得更加复杂,并引入潜在的延迟问题,因为关键任务被推迟,而不太关键的任务正在阻塞资源。对于某些任务,特别是那些尴尬地并行的任务,使用通用处理器也非常有限。
对于需要使用相同算法处理单个大型数据集的任务,使用基于通用图形处理单元(GPGPU)的处理已经变得非常流行,同时还使用数字信号处理器(DSP)通过使用专用硬件大大加速一系列操作。
在这个问题的另一面是任务,这些任务是大规模并行的,但涉及对传入数据、内部数据或两者都进行许多不同操作。如果纯粹在软件中实现一系列微处理器核心的范围内,要想获得合理的性能将会非常困难。
昂贵的 DSP 硬件可能会有所帮助,但即使如此,也不会针对该任务进行优化。传统上,这将是公司考虑设计和生产应用特定集成电路(ASIC)的时候。然而,这样做的成本非常高,只有在大规模生产中才是现实的,它才能与其他选项竞争。
随着时间的推移,发明了不同的解决方案,使得这种定制硬件实现更加现实,其中之一就是可编程逻辑芯片的开发。例如,像 Commodore 64 这样的系统包含一个PLA(原名可编程逻辑阵列,最初是 Signetics 82S100)芯片,它是一次性可编程的组合逻辑元素阵列。它允许处理器重新配置地址总线的内部路由,以改变 DRAM 存储芯片、ROM 存储芯片和其他外围设备在活动寻址空间中的部分。
在编程 PLA 之后,它的功能基本上与大量的 74 逻辑芯片(离散逻辑芯片)以相同的方式运行,但所需空间仅为离散解决方案的一小部分。这种方法本质上为 Commodore 提供了他们自己的定制 ASIC,但无需投资设计和生产。相反,他们使用了现成的零件,并且可以在 Commodore 64 的生命周期内对烧入 PLA 芯片的逻辑进行改进。
随着时间的推移,PLAs(也称为 PALs)变得更加先进,发展成为基于宏单元的复杂可编程逻辑设备(CPLDs),这些设备允许实现更高级的功能,而不仅仅是简单的组合逻辑。这些最终演变成了 FPGAs,再次增加了更高级的功能和外围设备。
如今,几乎在所有需要一些高级处理或控制的地方都可以找到 FPGAs。视频和音频处理设备通常与 DSP 一起使用 FPGAs,MCU 或 SoC 处理用户界面和其他低优先级功能。
如今,示波器等设备采用模拟(如果支持的话还有数字)前端,DSP 进行数据的原始转换和初始处理,然后将数据传递给一个或多个 FPGAs,FPGAs 进行进一步的处理和分析数据。处理后,这些数据可以存储在缓冲区(数字存储示波器(DSO)的“数字存储”部分),也可以传递给前端,在那里运行在 SoC 上的软件将在用户界面中呈现它,并允许用户输入命令来操作显示的数据。
在本章中,我们将介绍一个基本示波器项目,该项目将使用简单的硬件和使用 VHDL 代码编程的 FPGA 来实现。
硬件描述语言
随着过去几十年超大规模集成(VLSI)电路的复杂性增加,改进开发过程的能力,包括验证设计的能力,变得越来越关键。这导致了硬件描述语言(HDL****s)的发展,其中今天 VHDL 和 Verilog 是最常用的两种。
HDL 的主要目的是允许开发人员轻松描述硬件电路,这些电路可以集成到 ASIC 中或用于编程 FPGAs。此外,这些 HDL 还使得可以模拟设计并验证其功能正确性。
在本章中,我们将介绍一个使用 VHDL 实现在 FPGA 上的编程的示例。VHSIC 硬件描述语言(VHDL)作为一种语言于 1983 年首次出现,当时由美国国防部开发。它旨在作为一种记录供应商提供设备的 ASIC 行为的方式。
随着时间的推移,人们提出了这些文档文件可以用于模拟 ASIC 的行为的想法。这一发展很快被综合工具的发展所跟随,以创建可用于创建 ASIC 的功能硬件实现。
VHDL 在很大程度上基于 Ada 编程语言,Ada 本身也源自美国军方。虽然 VHDL 主要用作 HDL,但它也可以像 Ada 及其衍生语言一样用作通用编程语言。
FPGA 架构
尽管并非每个 FPGA 的结构都相同,但一般原则仍然相同:它们是可以配置为形成特定电路的逻辑元素阵列。因此,这些逻辑元素(LEs)的复杂性决定了可以形成什么样的逻辑电路,在为特定 FPGA 架构编写 VHDL 代码时必须考虑到这一点。
术语逻辑元素(LEs)和逻辑单元(LCs)可以互换使用。一个 LE 由一个或多个查找表(LUTs)组成,通常每个 LUT 具有四到六个输入。无论确切的配置如何,每个 LE 都被互连逻辑所包围,这允许不同的 LE 相互连接,LE 本身被编程为特定的配置,从而形成预期的电路。
开发 FPGA 的潜在风险包括 FPGA 制造商强烈假设 FPGA 将用于时钟设计(使用中央时钟源和时钟域),而不是组合逻辑(无时钟)的设计。一般来说,在将其包含在新项目中之前,熟悉目标 FPGA 系统是个好主意,以了解它能够支持你需要的功能有多好。
混合 FPGA/SoC 芯片
尽管多年来包含 FPGA 和 SoC 的系统非常常见,但最近增加了混合 FPGA/SoC 芯片,其中在同一封装中包含了 FPGA 和 SoC(通常是基于 ARM 的)。然后,它们通过总线连接在一起,以便两者可以使用内存映射 I/O 等方式有效地相互通信。
目前这类 FPGA 的常见示例包括 Altera(现在是英特尔)的 Cyclone V SoC 和 Xilinx 的 Zynq。Cyclone V SoC 的官方数据表中的块图给出了这种系统工作方式的很好概述:
在这里,我们可以看到 HPS 和 FPGA 两侧可以相互通信的多种方式,比如通过共享 SDRAM 控制器、两个点对点链接和其他一些接口。对于 Cyclone V SoC,系统启动时 FPGA 或 SoC 两侧可以是首先启动的一侧,从而可以实现广泛的系统配置选项。
示例-基本示波器
这个示例基本上介绍了如何在嵌入式项目中使用 FPGA。它使用 FPGA 对输入进行采样并测量电压或类似的东西,就像示波器一样。然后,得到的 ADC 数据通过串行链路发送到一个基于 C++/Qt 的应用程序中,该应用程序显示数据。
硬件
在这个项目中,我们将使用 Fleasystems FleaFPGA Ohm 板(fleasystems.com/fleaFPGA_Ohm.html
)。这是一个小型的、低于 50 美元、低于 40 欧元的 FPGA 开发板,外形尺寸与树莓派 Zero 相同:
它具有以下规格:
-
ECP5 FPGA 芯片具有 24K 个 LUT 元素和 112KB 的块 RAM。
-
256-Mbit SDRAM,16 位宽,167 MHz 时钟。
-
8-Mbit SPI Flash ROM,用于 FPGA 配置存储。
-
25 MHz 晶体振荡器。
-
HDMI 视频输出(最高 1080p30 或 720p60 屏幕模式)。
-
μSD 卡槽。
-
两个 Micro USB 主机端口,具有备用的 PS/2 主机端口功能。
-
29 个用户 GPIO,包括 4 个中速 ADC 输入和 12 对 LVDS 信号对,分别来自(与树莓派兼容的)40 针扩展和 2 针复位头。
-
一个 Micro USB 从机端口。提供+5V 供电给 Ohm,串行控制台/UART 通信,以及访问板载 JTAG 编程接口(用于配置 ECP5 FPGA)。
-
提供外部 JTAG 编程接口,以实现实时调试。
我们连接到这块板子上的电路可以让我们连接示波器探头:
这个电路将连接到 Ohm 板的 GPIO 引脚 29 号,对应 GPIO 5。它允许我们测量 0 到 3V 的直流信号,以及 1.5V 的交流(有效值),在 1x 探头测量模式下。带宽略高于 10 MHz。
VHDL 代码
在这一部分,我们将看一下 VHDL 项目中的顶层实体,以了解它的功能。它以 VHDL 的标准库包含开始,如下所示:
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.std_logic_unsigned.ALL;
use IEEE.numeric_std.all;
entity FleaFPGA_Ohm_A5 is
port(
sys_clock : in std_logic; -- 25MHz clock input from external xtal oscillator.
sys_reset : in std_logic; -- master reset input from reset header.
这映射到底层 FPGA 的系统时钟和复位线。我们还可以看到端口映射的工作方式,定义了实体端口的方向和类型。在这里,类型是std_logic
,它是一个标准逻辑信号,可以是二进制的 1 或 0:
n_led1 : buffer std_logic;
LVDS_Red : out std_logic_vector(0 downto 0);
LVDS_Green : out std_logic_vector(0 downto 0);
LVDS_Blue : out std_logic_vector(0 downto 0);
LVDS_ck : out std_logic_vector(0 downto 0);
slave_tx_o : out std_logic;
slave_rx_i : in std_logic;
slave_cts_i : in std_logic; -- Receive signal from #RTS pin on FT230x
我们还使用了板上的状态 LED,映射了 HDMI 的视频引脚(LVDS 信号),以及使用了板上的 FDTI USB-UART 芯片的 UART 接口。后者是我们将用来将数据从 FPGA 发送到 C++ 应用程序的。
接下来是树莓派兼容的标题映射,如下所示的代码:
GPIO_2 : inout std_logic;
GPIO_3 : inout std_logic;
GPIO_4 : inout std_logic;
-- GPIO_5 : inout std_logic;
GPIO_6 : inout std_logic;
GPIO_7 : inout std_logic;
GPIO_8 : inout std_logic;
GPIO_9 : inout std_logic;
GPIO_10 : inout std_logic;
GPIO_11 : inout std_logic;
GPIO_12 : inout std_logic;
GPIO_13 : inout std_logic;
GPIO_14 : inout std_logic;
GPIO_15 : inout std_logic;
GPIO_16 : inout std_logic;
GPIO_17 : inout std_logic;
GPIO_18 : inout std_logic;
GPIO_19 : inout std_logic;
GPIO_20 : in std_logic;
GPIO_21 : in std_logic;
GPIO_22 : inout std_logic;
GPIO_23 : inout std_logic;
GPIO_24 : inout std_logic;
GPIO_25 : inout std_logic;
GPIO_26 : inout std_logic;
GPIO_27 : inout std_logic;
GPIO_IDSD : inout std_logic;
GPIO_IDSC : inout std_logic;
GPIO 5 被注释掉的原因是因为我们想要将其用于 ADC 功能而不是通用输入/输出。
相反,我们启用了 sigma-delta-capable ADC3 外设来处理该引脚的工作,如下所示:
--ADC0_input : in std_logic;
--ADC0_error : buffer std_logic;
--ADC1_input : in std_logic;
--ADC1_error : buffer std_logic;
--ADC2_input : in std_logic;
--ADC2_error : buffer std_logic;
ADC3_input : in std_logic;
ADC3_error : buffer std_logic;
在这里,我们看到我们还有另外三个 ADC 外设,如果我们想要为示波器添加额外的通道,可以使用这些外设,如下所示的代码:
mmc_dat1 : in std_logic;
mmc_dat2 : in std_logic;
mmc_n_cs : out std_logic;
mmc_clk : out std_logic;
mmc_mosi : out std_logic;
mmc_miso : in std_logic;
PS2_enable : out std_logic;
PS2_clk1 : inout std_logic;
PS2_data1 : inout std_logic;
PS2_clk2 : inout std_logic;
PS2_data2 : inout std_logic
);
end FleaFPGA_Ohm_A5;
顶层的实体定义以 MMC(SD 卡)和 PS2 接口结束。
接下来是模块的架构定义。这部分类似于 C++ 应用程序的源文件,实体定义的功能类似于标题,如下所示:
architecture arch of FleaFPGA_Ohm_A5 is
signal clk_dvi : std_logic := '0';
signal clk_dvin : std_logic := '0';
signal clk_vga : std_logic := '0';
signal clk_50 : std_logic := '0';
signal clk_pcs : std_logic := '0';
signal vga_red : std_logic_vector(3 downto 0) := (others => '0');
signal vga_green : std_logic_vector(3 downto 0) := (others => '0');
signal vga_blue : std_logic_vector(3 downto 0) := (others => '0');
signal ADC_lowspeed_raw : std_logic_vector(7 downto 0) := (others => '0');
signal red : std_logic_vector(7 downto 0) := (others => '0');
signal green : std_logic_vector(7 downto 0) := (others => '0');
signal blue : std_logic_vector(7 downto 0) := (others => '0');
signal hsync : std_logic := '0';
signal vsync : std_logic := '0';
signal blank : std_logic := '0';
这里定义了一些信号。这些信号允许我们将 VHDL 模块的端口、实体、进程和其他元素相互连接。
我们可以看到这里定义了一些信号以支持 VGA。这允许与支持 VGA 的 FPGA 板兼容,但其中的部分也与 HDMI(或 DVI)外设兼容,我们稍后将会看到。让我们看看以下代码:
begin
Dram_CKE <= '0'; -- DRAM Clock disable.
Dram_n_cs <= '1'; -- DRAM Chip disable.
PS2_enable <= '1'; -- Configures both USB host ports for legacy PS/2 mode.
mmc_n_cs <= '1'; -- Micro SD card chip disable.
通过 begin
关键字,我们指示这是我们希望开始执行架构定义中的命令的地方。除非一组指令被封装在 process
中(在此代码中未显示),否则在此关键字之后和终止关键字(end architecture
)之前的所有内容将同时执行。
通过写入适当的引脚,我们禁用了一些硬件功能。出于简洁起见,我们在早期的实体定义中省略了 DRAM(外部内存)部分。DRAM 和 SD 卡功能被禁用,而 PS2(键盘、鼠标)功能被启用。这样,我们就可以连接 PS2 输入设备,如果我们想的话:
user_module1 : entity work.FleaFPGA_DSO
port map(
rst => not sys_reset,
clk => clk_50,
ADC_1 => n_led1,
ADC_lowspeed_raw => ADC_lowspeed_raw,
Sampler_Q => ADC3_error,
Sampler_D => ADC3_input,
Green_out => vga_green,
Red_out => vga_red,
Blue_out => vga_blue,
VGA_HS => hsync,
VGA_VS => vsync,
blank => blank,
samplerate_adj => GPIO_20,
trigger_adj => GPIO_21
);
在这里,我们定义将使用 FleaFPGA 数字存储示波器模块的一个实例。虽然模块可以支持四个通道,但只映射了第一个通道。这种简化有助于演示操作原理。
DSO 模块负责从 ADC 中读取数据,因为它对我们用探头测量的信号进行采样,并将其呈现到本地缓存以在本地(HDMI 或 VGA)监视器上显示,并通过串行接口发送到 UART 模块(在本节末尾显示)。让我们看看以下代码:
red <= vga_red & "0000";
green <= vga_green & "0000";
blue <= vga_blue & "0000";
在这里,显示输出的最终颜色是通过 HDMI 输出信号确定的:
u0 : entity work.DVI_clkgen
port map(
CLKI => sys_clock,
CLKOP => clk_dvi,
CLKOS => clk_dvin,
CLKOS2 => clk_vga,
CLKOS3 => clk_50
);
u100 : entity work.dvid PORT MAP(
clk => clk_dvi,
clk_n => clk_dvin,
clk_pixel => clk_vga,
red_p => red,
green_p => green,
blue_p => blue,
blank => blank,
hsync => hsync,
vsync => vsync,
-- outputs to TMDS drivers
red_s => LVDS_Red,
green_s => LVDS_Green,
blue_s => LVDS_Blue,
clock_s => LVDS_ck
);
整个部分用于输出由 DSO 模块生成的视频信号,这样我们也可以将 FPGA 板用作独立示波器单元:
myuart : entity work.simple_uart
port map(
clk => clk_50,
reset => sys_reset, -- active low
txdata => ADC_lowspeed_raw,
--txready => ser_txready,
txgo => open,
--rxdata => ser_rxdata,
--rxint => ser_rxint,
txint => open,
rxd => slave_rx_i,
txd => slave_tx_o
);
end architecture;
最后,简单的 UART 实现允许 DSO 模块与我们的 C++ 应用程序进行通信。
UART 配置为工作在波特率为 19,200,8 位,1 个停止位,无校验。构建了这个 VHDL 项目并用 FPGA 板进行了编程后,我们可以通过这个串行连接连接到它。
C++ 代码
虽然 VHDL 代码实现了简单的显示输出和基本的输入选项,但如果我们想要有一个大型(高分辨率)显示屏,进行信号分析,录制多分钟甚至几小时的数据等,能够在 SBC 上进行这些操作将非常方便。
以下代码是作为一个 C++/Qt 图形应用程序编写的,它从 FPGA 板接收原始 ADC 数据并在图表中显示。虽然简陋,但它为一个功能齐全的基于 SoC 的系统提供了框架。
首先,显示标题如下:
#include <QMainWindow>
#include <QSerialPort>
#include <QChartView>
#include <QLineSeries>
namespace Ui {
class MainWindow;
}
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
public slots:
void connectUart();
void disconnectUart();
void about();
void quit();
private:
Ui::MainWindow *ui;
QSerialPort serialPort;
QtCharts::QLineSeries* series;
quint64 counter = 0;
private slots:
void uartReady();
};
在这里,我们可以看到我们将在 Qt 中使用串行端口实现,以及 QChart 模块进行可视化部分。
实现如下代码所示:
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include <QSerialPortInfo>
#include <QInputDialog>
#include <QMessageBox>
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent),
ui(new Ui::MainWindow) {
ui->setupUi(this);
// Menu connections.
connect(ui->actionQuit, SIGNAL(triggered()), this, SLOT(quit()));
connect(ui->actionConnect, SIGNAL(triggered()), this, SLOT(connectUart()));
connect(ui->actionDisconnect, SIGNAL(triggered()), this, SLOT(disconnectUart()));
connect(ui->actionInfo, SIGNAL(triggered()), this, SLOT(about()));
// Other connections
connect(&serialPort, SIGNAL(readyRead()), this, SLOT(uartReady()));
// Configure the chart view.
QChart* chart = ui->chartView->chart();
chart->setTheme(QChart::ChartThemeBlueIcy);
chart->createDefaultAxes();
series = new QtCharts::QLineSeries(chart);
chart->setAnimationOptions(QChart::NoAnimation);
chart->addSeries(series);
}
在构造函数中,我们创建了与 GUI 中菜单选项的连接,这些选项允许我们退出应用程序,连接到串行端口,如果已连接则断开与串行端口的连接,或者获取有关应用程序的信息。
我们将串行端口实例连接到一个插槽,每当准备读取新数据时,该插槽将被调用。
最后,我们在 GUI 中配置图表视图,获取 QChartView 小部件内的 QChart 实例的引用。在这个引用上,我们为图表设置了一个主题,添加了默认轴,最后添加了一个空系列,我们将用来填充来自 FPGA 的传入数据,如下面的代码所示:
MainWindow::~MainWindow() {
delete ui;
}
void MainWindow::connectUart() {
QList<QSerialPortInfo> comInfo = QSerialPortInfo::availablePorts();
QStringList comNames;
for (QSerialPortInfo com: comInfo) {
comNames.append(com.portName());
}
if (comNames.size() < 1) {
QMessageBox::warning(this, tr("No serial port found"), tr("No serial port was found on the system. Please check all connections and try again."));
return;
}
QString comPort = QInputDialog::getItem(this, tr("Select serial port"), tr("Available ports:"), comNames, 0, false);
if (comPort.isEmpty()) { return; }
serialPort.setPortName(comPort);
if (!serialPort.open(QSerialPort::ReadOnly)) {
QMessageBox::critical(this, tr("Error"), tr("Failed to open the serial port."));
return;
}
serialPort.setBaudRate(19200);
serialPort.setParity(QSerialPort::NoParity);
serialPort.setStopBits(QSerialPort::OneStop);
serialPort.setDataBits(QSerialPort::Data8);
}
当用户希望通过 UART 连接到 FPGA 时,必须选择连接 FPGA 的串行连接,之后将建立连接,使用我们在项目的 VHDL 部分中之前建立的 19,200 波特率,8N1 设置。
对于固定配置,其中串行端口始终相同,可以考虑在系统启动时自动化以下部分:
void MainWindow::disconnectUart() {
serialPort.close();
}
从串行端口断开连接非常简单:
void MainWindow::uartReady() {
QByteArray data = serialPort.readAll();
for (qint8 value: data) {
series->append(counter++, value);
}
}
当 UART 从 FPGA 板接收新数据时,将调用此插槽。在其中,我们从 UART 缓冲区中读取所有数据,将其附加到我们添加到图形小部件的系列中,从而更新显示的跟踪。计数器变量用于为图表提供递增的时间基准。这在这里充当了简单的时间戳。
在某个时候,我们应该开始从系列中删除数据,以防止其变得过大,同时具有搜索和保存数据的能力。基于计数器的时间戳可以报告我们接收信号的实际时间,尽管理想情况下,这应该是我们从 FPGA 接收到的数据的一部分:
void MainWindow::about() {
QMessageBox::aboutQt(this, tr("About"));
}
void MainWindow::quit() {
exit(0);
}
最后,我们有一些简单的插槽。对于信息对话框,我们只需显示标准的 Qt 信息对话框。这可以替换为自定义的帮助或信息对话框。
构建项目
可以使用免费的 Lattice Semiconductor Diamond IDE 软件(www.latticesemi.com/latticediamond
)构建 VHDL 项目,并将其编程到 Ohm FPGA 板上。编程板需要安装来自github.com/Basman74/FleaFPGA-Ohm
的 FleaFPGA JTAG 实用程序,以便 Diamond 可以使用它。
通过按照快速入门指南中描述的 FleaFPGA Ohm 板的说明,应该相对容易地启动和运行项目的一部分。对于 C++部分,必须确保 FPGA 板和 SBC(或等效物)连接在一起,以便后者可以访问前者上的 UART。
有了这个设置,只需使用 Qt 框架编译 C++项目(直接在 SBC 上或最好是在桌面系统上进行交叉编译)就足够了。之后,可以运行已刷写 FPGA 板的应用程序,连接到 UART,并观察在应用程序窗口上绘制的跟踪。
摘要
在本章中,我们看了 FPGA 在嵌入式开发中扮演的角色,它们在过去几十年中的重要性发生了变化,以及它们现在的用途。我们看了一个使用 FPGA 和基于 SBC 的组件的示波器的简单实现。阅读完本章后,您现在应该知道何时选择 FPGA 用于新的嵌入式项目,并了解如何使用和与这样的设备通信。
第十二章:最佳实践
与每个软件项目一样,存在许多常见问题和陷阱。在嵌入式开发中,硬件方面增加了独特的问题。从资源管理问题到中断故障和硬件问题引起的奇怪行为,本附录向您展示如何预防和处理许多这些问题。此外,它还向您展示了各种优化方法以及需要注意的事项。在本附录中,我们将涵盖以下主题:
-
优化嵌入式代码的安全方法
-
如何避免和解决各种常见的软件和硬件相关问题
-
认识到硬件的不完美世界以及如何将其整合到设计中
所有最好的计划
与任何项目一样,预期设计与实际功能之间存在不可避免的差距。即使有最好的规划和丰富的经验,也总会有意想不到或未被注意到的问题。您能做的最好的事情就是尽可能做好准备。
第一步是要获得目标平台的所有可用信息,了解可用的工具,并拥有一个坚实的开发和测试计划。我们在本书中已经涵盖了许多这些方面。
在本附录中,我们将总结一些最佳实践,这些实践应该有助于避免一些更常见的问题。
与硬件合作
每个目标平台都有其自己的怪癖和特点。其中很大一部分是由于该平台的发展历史。对于 AVR 这样的平台,它相当一致,因为它是由一家公司(Atmel)在多年内开发的,因此在不同芯片和用于该平台的工具之间相当一致。
像 ESP8266(以及在某种程度上其 ESP32 后继者)这样的平台从未被设计为用作通用 MCU 系统,这在其相当零碎和分散的软件生态系统中表现出来。尽管在过去几年中情况有所好转,各种框架和开源工具平滑了最粗糙的地方,但由于缺乏文档、工具问题和芯片内调试的缺乏,这是一个容易犯错误的平台。
ARM MCU(Cortex-M)由众多制造商生产,配置繁多。尽管编程这些 MCU 往往是相当一致的,使用诸如 OpenOCD 之类的工具,但每个 MCU 添加的外设在制造商之间往往大不相同,我们将在下一节中进行讨论。
最后,ARM SoCs 和类似的平台与 ARM MCU 类似,但其体系结构更加复杂,外设较少。此外,ARM SoCs 还添加了复杂的初始化程序,需要全面的引导加载程序,这就是为什么大多数人选择使用现成的 Linux 镜像或类似的 SoC,并对其进行开发。
在这里,没有真正的对与错的答案。大部分取决于项目的需求,但重要的是您对所使用的硬件平台有一个良好的概述。
外设的混乱世界
ARM MCU 的一个非常有趣的现实是,它们具有不同且通常不兼容的外设,映射到内存空间中高度不同的区域。最糟糕的是定时器外设,其复杂性各不相同,它们通常能够在 GPIO 引脚上生成任何所需的输出信号,包括 PWM,以及作为基于中断的定时器来控制固件的执行。
配置定时器外设和类似的复杂外设并不是一件简单的事情。同样,使用内置 MAC 与外部 PHY(以太网物理接口)需要大量深入的知识来了解如何配置它们。阅读数据手册和应用笔记在这里是必不可少的。
依赖诸如 ST 的 CubeMX 软件之类的工具生成的代码,用于他们的 STM32 系列 ARM MCU,可能会导致你因为忘记在 CubeMX 编辑器中勾选一些选项而与非功能性代码搏斗,因为你不知道这些选项是用来做什么的。
使用这种自动生成工具或制造商提供的高级库没有错,因为它们可以显著地简化生活。然而,接受这一决定所带来的风险是至关重要的,因为这需要你相信提供的代码是正确的,或者花时间验证它确实是正确的。
为了使不同 MCU 和 SoC 上的外设使用不那么混乱,必须在某个地方添加一层抽象,以便实现代码的可移植性。关键是确保这确实会让生活变得更容易,而不仅仅是增加可能会使当前项目或未来项目受挫的另一个潜在问题。
了解你的工具
在嵌入式项目中工作时,你必须知道目标平台上存在哪些工具以及它们的工作原理。这包括通过 JTAG 或其他接口对 MCU 进行编程,并启动用于片上调试的调试会话,以及片上调试的限制。在使用工具之前,最好先阅读工具的手册或文档,并阅读其他开发人员对这些工具的经验。
我们在之前的章节中看过许多这样的工具,包括 MCU 和 SoC 平台,以及在将其刷入目标硬件之前验证 MCU 设计的方法。
选择异步方法
许多硬件设备和操作需要时间来完成。因此,选择使用中断和定时器进行异步操作而不是阻塞操作是有意义的。
在进行裸机编程时,你往往会使用一个单独的循环,其中包含中断例程和定时器,允许你响应和轮询事件。如果以完全异步的方式编程,这个主循环将有效地处理任务,而中断处理程序将更新需要处理的数据。
即使在 SoC 平台上,使用异步方法也是一个好主意,因为诸如网络操作和其他 I/O 操作之类的事情可能需要比预期更长的时间。处理操作未完成的方法是另一个可能出现的问题。
阅读数据表
特别是对于 MCU,数据表为我们提供了关于硬件工作方式的许多宝贵信息,例如如何配置内部系统时钟,各个外设的工作方式,以及可用的寄存器及其含义。
即使你使用的是现有的板而不是自定义硬件系统,了解底层硬件也是值得的,即使只是从对 MCU 或 SoC 数据表的粗略阅读中。
保持中断处理程序的简短
中断的本质决定了它会打断处理器的正常执行,转而执行中断处理程序。我们在中断处理程序中花费的每一微秒都意味着我们无法运行其他例程或处理其他中断。
为了防止由此产生的任何问题,中断处理程序(ISR)应尽可能保持简短,最好只是在结束 ISR 并恢复正常操作之前以快速和安全的方式更新单个值。
8 位意味着 8 位
毫不奇怪,在 8 位 MCU 上使用 16 位和 32 位整数非常慢。这是因为系统必须对相同的整数值执行多次操作,因为它一次只能将 8 位装入其寄存器。
同样,对于没有浮点单元(FPU)的系统使用浮点变量意味着这样的操作非常适合使系统变得非常缓慢,因为只有整数处理器在努力跟上旨在模拟浮点操作的指令流。
不要重复造轮子
如果存在一个质量良好且适用于目标平台和项目许可的库或框架,那么使用它而不是编写自己的实现。
保留一个常用片段和示例的库作为参考,不仅是为了自己,也是为了其他团队成员。记住可以找到某个功能示例的地方比记住该功能的确切实现细节更容易。
在优化之前三思
优化代码的诀窍在于,在没有充分了解你提出的改变会产生什么影响的情况下,你不应该尝试这样做。仅仅有一种感觉或模糊的想法可能不够好。
虽然基于 SoC 的平台通常会给你更多的余地,但对于 MCU 平台来说,了解添加一个关键字或使用不同的数据结构来存储一些信息将意味着什么是至关重要的。
在这里最糟糕的事情是假设你在 SBC 和台式系统上使用的优化方法会对 MCU 平台产生类似的效果。由于修改后的哈佛架构和 AVR 等平台的各种怪癖,这些方法很可能会适得其反,或者幸运的话,只是无效。
在这里,为(MCU)平台提供的应用程序说明对于了解如何优化硬件非常有用。这里的要点是在进行优化之前进行研究,就像在考虑项目设计之前不会只是开始编写代码一样。
需求不是可选的
在没有为项目制定明确的需求的情况下编写嵌入式软件,就好像开始建造新房子却不清楚应该有多少个房间,窗户和门应该在哪里,以及管道和电线应该走在哪里。
虽然你完全可以开始编写可工作的代码,并在短时间内制作出一个功能原型,但现实是,这些原型通常会在没有充分考虑产品生命周期或那些将不得不在未来几年内不断修补固件以添加原始固件代码从未设计的功能的时间内投入生产。
完成产品需满足的需求后,这些需求被转化为架构(应用程序的整体结构),然后转化为设计(将要实现的内容)。设计然后被转化为实际的代码。
这种方法的优势在于,不仅需要回答很多关于为什么以某种特定方式完成某事的问题,而且还会产生大量文档,一旦项目完成,这些文档就可以实际使用。
此外,在嵌入式项目中,具备完整的需求集可以节省大量时间和金钱,因为它可以让人们在不必为了“以防万一”而在更强大的芯片上花费更多金钱,而是可以为项目选择合适的 MCU 或 SoC。它还可以防止尴尬的中期发现,即“遗忘”的功能突然需要改变硬件设计。
文档能拯救生命
程序员不喜欢编写文档,因此他们称自己编写的代码为“自我说明的代码”,这已经成为了一个笑话。事实是,如果没有清晰的设计需求、架构概述、设计计划和 API 文档,你就会冒着项目的未来以及依赖软件运行的其他开发人员和最终用户的风险。
在你开始编写第一行代码之前,按照程序进行所有无聊的文书工作似乎是完全令人沮丧的。不幸的是,现实情况是,如果没有这种努力,这些知识将继续锁在项目开发人员的头脑中,这会使固件集成到嵌入式项目的其余部分变得复杂,并且使未来的维护,特别是如果转移到不同的团队,成为一个艰巨的前景。
事实很简单,没有代码是自我说明的,即使是这样,也没有硬件工程师会浏览成千上万行的代码,以弄清在特定的输入条件下在特定的 GPIO 引脚上输出了什么样的信号。
测试代码意味着试图摧毁它
编写测试时的一个常见错误是编写你期望能够正常工作的测试场景。这样做是错过了重点。虽然当一个特定的解析例程在处理完美格式的数据时做到了它应该做的事情是很美妙的,但在现实场景中并没有多大帮助。
虽然你可能会得到完美的数据,但同样有可能在你的代码中得到完全损坏甚至垃圾数据。目标是确保无论你对输入数据做了多么可怕的事情,它都不会对系统的其余部分产生负面影响。
所有输入都应该经过验证和检查。如果有什么不对劲,就应该拒绝,而不是允许它在以后的代码中引起问题。
总结
在这个附录中,我们列举了在嵌入式软件设计中可能出现的一些常见问题和陷阱。
读者现在应该知道项目中存在哪些阶段,以及在项目的每一步都有记录的原因。