效果

主题:evan

主题:dallas

主题:robbyrussell

如果原先其他电脑安装过

.oh-my-zsh整个文件夹,.zshrc.zsh_history复制到/home/user/目录;

安装zsh

1
2
sudo apt install zsh

切换shell

1
chsh -s /bin/zsh
1
source ~/.zshrc

即可使用。所有配置都会和原先一样。

如果是新安装

官方方法,curlwget二选一即可

1
2
curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh
wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh

应该也有人和我一样,可能会遇到连接 GitHub 失败的问题,要不就是 SSL 验证失败,要不就是连接无响应。可以更换下面的方法。

1
2
3
4
# 先下载
git clone git://github.com/robbyrussell/oh-my-zsh.git ~/.oh-my-zsh
## 再替换
cp ~/.oh-my-zsh/templates/zshrc.zsh-template ~/.zshrc

重启终端即可成功。

如果无法访问 GitHub,其实oh-my-zsh并不需要安装,完整的工程就是oh-my-zsh本体,只要想办法把整个工程下载下来,并重命名为oh-my-zsh即可。所以找找 gitee 有没有相关工程。这也是为什么从旧电脑里直接复制.oh-my-zsh就能用的原因。

问题

oh-my-zsh.sh parse error near `<<<’

一般是在更新oh-my-zsh时出现,因为更新相当于就是从远程拉取了内容,可能本地的oh-my-zsh.sh脚本自己做了修改与远程冲突了。只要退回上个版本,重新拉取就可以了。

1
2
3
cd $ZSH
git reset --hard HEAD^
git pull --rebase

如果本地修改了一些内容需要保留,可以打开oh-my-zsh.sh看看冲突在哪,自己做个备份,保存一下。

本篇文章所涉及代码可在此处查看

绘制系统简介

Qt 的绘图系统允许使用相同的 API 在屏幕和其它打印设备上进行绘制。整个绘图系统基于QPainter,QPainterDeviceQPaintEngine三个类。

QPainter用来执行绘制的操作;QPaintDevice是一个二维空间的抽象,这个二维空间允许QPainter在其上面进行绘制,也就是QPainter工作的空间;QPaintEngine提供了画笔(QPainter)在不同的设备上进行绘制的统一的接口。QPaintEngine类应用于QPainterQPaintDevice之间,通常对开发人员是透明的。

三个类的关系:QPainter->QPaintEngine->QPaintDevice。通过这个关系我们也可以知道,QPainter通过QPaintEngine翻译指令在QPaintDevice上绘制。

通过一个实例来了解一下绘制系统的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//main.h
#include <QPainter>
#include <QWidget>
#include <QPaintEvent>
#include <QApplication>
#include <QMainWindow>

class PaintedWidget : public QWidget
{
Q_OBJECT
public:
PaintedWidget(QWidget *parent = 0);

protected:
void paintEvent(QPaintEvent *);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//main.cpp
#include "paintwidget.h"

PaintedWidget::PaintedWidget(QWidget *parent) : QWidget(parent)
{
resize(800, 600);
setWindowTitle(tr("Paint Demo"));
}

void PaintedWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.drawLine(20, 20, 700, 20);
painter.setPen(Qt::red);
painter.drawRect(10, 10, 100, 400);
painter.setPen(QPen(Qt::green, 5));
painter.setBrush(Qt::blue);
painter.drawEllipse(0, 0, 300, 40);
// painter.drawRect(120, 50, 50, 400);

}

int main(int argc, char *argv[])
{
QApplication app(argc, argv);
PaintedWidget paintMap;
paintMap.show();
return app.exec();
}

在构造函数中,我们仅仅设置了窗口的大小和标题。而paintEvent()函数则是绘制的代码。

首先,我们在栈上创建了一个QPainter对象,也就是说,每次运行paintEvent()函数的时候,都会重建这个QPainter对象。注意,这一点可能会引发某些细节问题:由于我们每次重建QPainter,因此第一次运行时所设置的画笔颜色、状态等,第二次再进入这个函数时就会全部丢失。有时候我们希望保存画笔状态,就必须自己保存数据,否则的话则需要将QPainter作为类的成员变量。

paintEvent()作为重绘函数,会在需要重绘时由 Qt 自动调用。“需要重绘”可能发生在很多地方,比如组件刚刚创建出来的时候就需要重绘;组件最大化、最小化的时候也需要重新绘制;组件由遮挡变成完全显示的时候也需要等等。

QPainter接收一个QPaintDevice指针作为参数。QPaintDevice有很多子类,比如QImage,以及QWidget。注意回忆一下,QPaintDevice可以理解成要在哪里去绘制,而现在我们希望画在这个组件,因此传入的是 this 指针。

我们还需要注意绘制的顺序,直线-矩形 - 椭圆,所以直线位于最下方,以此类推。

如果了解 OpenGL,肯定听说过这么一句话:OpenGL 是一个状态机。所谓状态机,就是说,OpenGL 保存的只是各种状态。比如,将画笔颜色设置成红色,那么,除非你重新设置另外的颜色,它的颜色会一直是红色。QPainter也是这样,它的状态不会自己恢复,除非你使用了各种设置函数。因此,如果在上面的代码中,我们在椭圆绘制之后再画一个矩形,它的样式还会是绿色5像素的轮廓线以及蓝色的填充,除非你显式地调用了设置函数进行状态的更新。

这是大多数绘图系统的实现方式,包括 OpenGLQPainter以及 Java2D。正因为QPainter是一个状态机,才会引出我们前面曾经介绍过的一个细节问题:由于paintEvent()是需要重复进入的,因此,需要注意第二次进入时,QPainter的状态是不是和第一次一致,否则的话可能会造成闪烁的现象。这个闪烁并不是由于双缓冲的问题,而是由于绘制状态的快速切换。

画刷和画笔

画刷和画笔。前者使用QBrush描述,大多用于填充;后者使用QPen描述,大多用于绘制轮廓线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//main.cpp
void PaintedWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.drawLine(20, 20, 700, 20);
painter.setPen(Qt::red);
painter.drawRect(10, 10, 100, 400);
painter.setPen(QPen(Qt::green, 5));
painter.setBrush(Qt::blue);
painter.drawEllipse(0, 0, 300, 40);
painter.drawRect(120, 50, 50, 400);
///////////////////画笔与笔刷
QLinearGradient gradient(QPointF(180, 50), QPointF(230, 400));
gradient.setColorAt(0, Qt::black);
gradient.setColorAt(1, Qt::red);
gradient.setSpread(QGradient::PadSpread);

QBrush brush(gradient);

QPen pen(Qt::green, 3, Qt::DashDotLine, Qt::RoundCap, Qt::RoundJoin);
// painter.setPen(pen);
painter.setBrush(brush);
painter.drawRect(180, 50, 50, 400);
}

画刷的style()定义了填充的样式,使用Qt::BrushStyle枚举,默认值是Qt::NoBrush,也就是不进行任何填充。我们可以从下面的图示中看到各种填充样式的区别:

画刷的gradient()定义了渐变填充。这个属性只有在样式是Qt::LinearGradientPatternQt::RadialGradientPattern或者Qt::ConicalGradientPattern之一时才有效。渐变可以由QGradient对象表示。Qt 提供了三种渐变:QLinearGradientQConicalGradientQRadialGradient,它们都是QGradient的子类。

本文以QLinearGradient为例,两个坐标分别为起点与重点坐标。setColorAt设置渐变颜色,0表示开始,1表示结束。意思就是从黑色渐变到红色。setSpread设置显示方式为平铺。

1
2
3
4
QLinearGradient gradient(QPointF(180, 50), QPointF(230, 400));
gradient.setColorAt(0, Qt::black);
gradient.setColorAt(1, Qt::red);
gradient.setSpread(QGradient::PadSpread);

默认的画笔属性是纯黑色,0 像素,方形笔帽(Qt::SquareCap),斜面型连接(Qt::BevelJoin)。

画笔样式有一下几种,

你也可以使用setDashPattern()函数自定义样式,例如如下代码片段:

1
2
3
4
5
6
7
8
9
QVector<qreal> dashes;
qreal space = 4;

dashes << 1 << space << 3 << space << 9 << space
<< 27 << space << 9 << space;
pen.setColor(Qt::black);
pen.setDashPattern(dashes);
painter.setPen(pen);
painter.drawLine(30, 300, 600, 30);

pen.setCapStyle(Qt::RoundCap)笔帽定义了画笔末端的样式,例如:

pen.setJoinStyle(Qt::RoundJoin)连接样式定义了两条线连接时的样式,例如:

反走样

我们在光栅图形显示器上绘制非水平、非垂直的直线或多边形边界时,或多或少会呈现锯齿状外观。这是因为直线和多边形的边界是连续的,而光栅则是由离散的点组成。在光栅显示设备上表现直线、多边形等,必须在离散位置采样。由于采样不充分重建后造成的信息失真,就叫走样;用于减少或消除这种效果的技术,就称为反走样。也就是常说的防锯齿现象。因为性能方面的考虑,Qt 默认关闭反走样。

1
2
3
4
5
6
7
8
9
10
11
12
13
void paintEvent(QPaintEvent *)
{
///////////////////对比反走样效果
QPainter painter(this);
painter.setPen(QPen(Qt::black, 5, Qt::DashDotLine, Qt::RoundCap));
painter.setBrush(Qt::yellow);
painter.drawEllipse(550, 150, 200, 150);

painter.setRenderHint(QPainter::Antialiasing, true);
painter.setPen(QPen(Qt::black, 5, Qt::DashDotLine, Qt::RoundCap));
painter.setBrush(Qt::yellow);
painter.drawEllipse(300, 150, 200, 150);
}

我们可以明显观察到右边的椭圆轮廓是有锯齿现象的,这两个椭圆除了位置位置不同,唯一的区别就是右边的开启了反锯齿。

1
painter.setRenderHint(QPainter::Antialiasing, true);

虽然反走样比不反走样的图像质量高很多,但是,没有反走样的图形绘制还是有很大用处的。首先,就像前面说的一样,在一些对图像质量要求不高的环境下,或者说性能受限的环境下,比如嵌入式和手机环境,一般是不进行反走样的。另外,在一些必须精确操作像素的应用中,也是不能进行反走样的。

坐标系统

在 Qt 的坐标系统中,每个像素占据 1x1 的空间。你可以把它想象成一张方格纸,每个小格都是 1 个像素。方格的焦点定义了坐标,也就是说,像素 (x, y) 的中心位置其实是在(x + 0.5, y + 0.5)的位置上。这个坐标系统实际上是一个“半像素坐标系”。我们可以通过下面的示意图来理解这种坐标系:

我们使用一个像素的画笔进行绘制,可以看到,每一个绘制像素都是以坐标点为中心的矩形。注意,这是坐标的逻辑表示,实际绘制则与此不同。因为在实际设备上,像素是最小单位,我们不能像上面一样,在两个像素之间进行绘制。所以在实际绘制时,Qt 的定义是,绘制点所在像素是逻辑定义点的右下方的像素。

接下来,我们探究 Qt 绘制图像的坐标情况,
对于画笔大小为一个像素的情况比较容易理解,当我们绘制矩形左上角 (1, 2) 时,实际绘制的像素是在右下方。

当画笔大小超过 1 个像素时,就略显复杂了。如果绘制像素是偶数,则实际绘制会包裹住逻辑坐标值;如果是奇数,则是包裹住逻辑坐标值,再加上右下角一个像素的偏移。具体请看下面的图示:

从上图可以看出,如果实际绘制是偶数像素,则会将逻辑坐标值夹在相等的两部分像素之间;如果是奇数,则会在右下方多出一个像素。

Qt 的这种处理,带来的一个问题是,我们可能获取不到真实的坐标值。由于历史原因,QRect::right()QRect::bottom()的返回值并不是矩形右下角点的真实坐标值:QRect::right()返回的是left() + width() - 1QRect::bottom()则返回 top() + height() - 1,上图的绿色点指出了这两个函数的返回点的坐标。

为避免这个问题,我们建议是使用QRectF。QRectF使用浮点值,而不是整数值,来描述坐标。这个类的两个函数QRectF::right()QRectF::bottom()是正确的。如果你不得不使用QRect,那么可以利用 x() + width()y() + height()来替代 right()bottom()函数。

对于反走样,实际绘制会包裹住逻辑坐标值:

前面说过,QPainter是一个状态机。那么,有时我想保存下当前的状态:当我临时绘制某些图像时,就可能想这么做。当然,我们有最原始的办法:将可能改变的状态,比如画笔颜色、粗细等,在临时绘制结束之后再全部恢复。对此,QPainter提供了内置的函数:save()restore()save()就是保存下当前状态;restore()则恢复上一次保存的结果。这两个函数必须成对出现:QPainter使用栈来保存数据,每一次save(),将当前状态压入栈顶,restore()则弹出栈顶进行恢复。

在了解了这两个函数之后,我们就可以进行示例代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//绘制一个网格背景
void CoordinateWidget::paintGrid()
{
size_t win_width = this->geometry().width();
size_t win_height = this->geometry().height();
QPainter painter(this);
for (size_t x = 0; x < win_width; x += 25)
{
painter.drawLine(QPoint(x, 1), QPoint(x, win_height));
}
for (size_t y = 0; y < win_height; y += 25)
{
painter.drawLine(QPoint(1, y), QPoint(win_width, y));
}
}
void CoordinateWidget::paintEvent(QPaintEvent *)
{
paintGrid();
QPainter painter(this);
painter.fillRect(10, 10, 50, 100, Qt::red);
painter.save();
painter.translate(100, 0); // 向右平移 100px
painter.fillRect(10, 10, 50, 100, Qt::yellow);
painter.restore();
painter.save();
painter.translate(300, 0); // 向右平移 300px
painter.rotate(30); // 顺时针旋转 30 度
painter.fillRect(10, 10, 50, 100, Qt::green);
painter.restore();
painter.save();
painter.translate(400, 0); // 向右平移 400px
painter.scale(2, 3); // 横坐标单位放大 2 倍,纵坐标放大 3 倍
painter.fillRect(10, 10, 50, 100, Qt::blue);
painter.restore();
painter.save();
painter.translate(600, 0); // 向右平移 600px
painter.shear(0, 1); // 横向不变,纵向扭曲 1 倍
painter.fillRect(10, 10, 50, 100, Qt::cyan);
painter.restore();
}

Qt 提供了四种坐标变换:平移 translate,旋转 rotate,缩放 scale 和扭曲 shear。在这段代码中,我们首先在 (10, 10) 点绘制一个红色的 50x100 矩形。保存当前状态,将坐标系平移到 (100, 0),绘制一个黄色的矩形。注意,translate()操作平移的是坐标系,不是矩形。因此,我们还是在(10, 10) 点绘制一个 50x100 矩形,现在,它跑到了右侧的位置。然后恢复先前状态,也就是把坐标系重新设为默认坐标系(相当于进行translate(-100, 0)),再进行下面的操作。之后也是类似的。由于我们只是保存了默认坐标系的状态,因此我们之后的translate()横坐标值必须增加,否则就会覆盖掉前面的图形。所有这些操作都是针对坐标系的,因此在绘制时,我们提供的矩形的坐标参数都是不变的。

为了更直观的查看绘制坐标,先在背景画了一个网格。

运行结果如下:

计算机基础

计算机硬件基础

两大硬件架构

  • 冯诺依曼架构

    • 一根总线,开销小,控制逻辑实现简单

    • 执行效率低

  • 哈佛架构

    • 与上一架构相反

程序的存储与执行

.c文件经过编译链接,生成.out文件。加载到内存中,到控制单元运行。进行取值,译码,执行。

晶振发出脉冲。

语言的设计与进化



上图是冯诺依曼架构,特点就是指令与数据放在一起。黄色部分表示指令,绿色部分表示数据。我们来看看指令是如何执行的。
ProgramCounter指到右图内存的第一条指令,程序开始执行。将第一条 指令读入指令寄存器。然后将指令解码,根据之前的规定,我们可以知道这条指令是将0100(二进制即 5)位置的数据,00(load)00(Register 0)中。下面的指令一次类推,每次取指,Program Counter移动一次。

了解了如何在VSCode 中调试程序,接下来我们在 VSCode 中搭建调试 QEMU 的环境。

配置

首先我们需要下载和编译 QEMU 源码

1
./configure --enable-debug --target-list=riscv32-softmmu,riscv32-linux-user --enable-kvm

一定要加上--enable-debug,编译出的程序才带有调试信息,不用设置安装路径,编译时会自动在 qemu 文件夹下自动创建一个build文件夹,编译后的程序也在build文件夹下。

用 VSCode 打开qemu-6.X.X文件夹,Ctrl+Shift+D打开调试配置。如果参考过VSCode 中调试程序这篇文章,接下来就很容易。我们只需要将launch.jason文件中的program属性改为${workspaceFolder}/build/qemu-system-riscv32即可。

调试

打开qemu-6.X.X/softmmu/main.c文件,在main函数入口处打上断点,即可开始调试。

现在只需要点击屏幕上的图标,就可以快速的进行单步调试。

如果需要进行命令行操作,在屏幕下方打开DEBUG CONSOLE,输入-exec+正常命令行下的命令即可在命令行中进行更多的调试。如查看断点信息-exec info breakpoints

设置一个旋转效果,将登录界面旋转翻个面,设置一些网络参数。

效果

网络参数设置界面布局

网络参数设置界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//loginnetsetwindow.cpp
//初始化标题
void LoginNetSetWindow::initMyTitle()
{
m_titleBar->move(0, 0);
m_titleBar->raise();
m_titleBar->setBackgroundColor(0, 0, 0, true);
m_titleBar->setButtonType(MIN_BUTTON);
m_titleBar->setTitleWidth(this->width());
m_titleBar->setMoveParentWindowFlag(false);
}

void LoginNetSetWindow::initWindow()
{
QLabel* pBack = new QLabel(this);
QMovie *movie = new QMovie();
movie->setFileName(":/Resources/NetSetWindow/headBack.gif");
pBack->setMovie(movie);
movie->start();
pBack->move(0, 0);

connect(ui.pButtonOk, SIGNAL(clicked()), this, SIGNAL(rotateWindow()));
connect(ui.pButtonCancel, SIGNAL(clicked()), this, SIGNAL(rotateWindow()));

ui.comboBoxNetType->addItem(QStringLiteral("不使用代理"));
ui.comboBoxServerType->addItem(QStringLiteral("不使用高级选项"));
}

void LoginNetSetWindow::paintEvent(QPaintEvent *event)
{
// 绘制背景图;
QPainter painter(this);
QPainterPath pathBack;
pathBack.setFillRule(Qt::WindingFill);
pathBack.addRoundedRect(QRect(0, 0, this->width(), this->height()), 3, 3);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.fillPath(pathBack, QBrush(QColor(235, 242, 249)));

QPainterPath pathBottom;
pathBottom.setFillRule(Qt::WindingFill);
pathBottom.addRoundedRect(QRect(0, 300, this->width(), this->height() - 300), 3, 3);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.fillPath(pathBottom, QBrush(QColor(205, 226, 242)));

painter.setPen(QPen(QColor(160 , 175 , 189)));
painter.drawRoundedRect(QRect(0, 0, this->width(), this->height()), 3, 3);
}

initMyTitle()就不多说了,和正面登录界面差不多。

QPainterPath

它是由一些图形如曲线、矩形、椭圆组成的对象。主要的用途是,能保存已经绘制好的图形。实现图形元素的构造和复用;图形状只需创建一次,然后调用QPainter::drawPath() 函数多次绘制。painterpath可以加入闭合或不闭合的图形 ( 如:矩形、椭圆和曲线) 。QPainterPath 可用于填充,描边,clipping。

setFillRule()设置填充模式

不是很理解
https://doc.qt.io/qt-5/qt.html#FillRule-enum

addRoundedRect(QRect(0, 0, this->width(), this->height()), 3, 3)圆角矩形

  • QRect(0, 300, this->width(), this->height() - 300)设置了矩形的位置及大小
  • (3,3)表示倒圆角的大小

setRenderHint()开启反走样

  • QPainter::Antialiasing 告诉绘图引擎应该在可能的情况下进行边的反锯齿绘制
  • QPainter::TextAntialiasing 尽可能的情况下文字的反锯齿绘制
  • QPainter::SmoothPixmapTransform 使用平滑的 pixmap 变换算法 (双线性插值算法),而不是近邻插值算

初始化旋转窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 初始化旋转的窗口;
void RotateWidget::initRotateWindow()
{
m_loginWindow = new LoginWindow(this);
// 这里定义了两个信号,需要自己去发送信号;
connect(m_loginWindow, SIGNAL(rotateWindow()), this, SLOT(onRotateWindow()));
connect(m_loginWindow, SIGNAL(closeWindow()), this, SLOT(close()));
connect(m_loginWindow, SIGNAL(hideWindow()), this, SLOT(onHideWindow()));

m_loginNetSetWindow = new LoginNetSetWindow(this);
connect(m_loginNetSetWindow, SIGNAL(rotateWindow()), this, SLOT(onRotateWindow()));
connect(m_loginNetSetWindow, SIGNAL(closeWindow()), this, SLOT(close()));
connect(m_loginNetSetWindow, SIGNAL(hideWindow()), this, SLOT(onHideWindow()));

this->addWidget(m_loginWindow);
this->addWidget(m_loginNetSetWindow);

// 这里宽和高都增加,是因为在旋转过程中窗口宽和高都会变化;
this->setFixedSize(QSize(m_loginWindow->width() + 20, m_loginWindow->height() + 100));
}

对正面和反面分别定义了信号槽,当对应的面接收到信号时,执行对应的动作。因为是旋转一百八十度,所以选择函数可以公用。

旋转窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

// 开始旋转窗口;
void RotateWidget::onRotateWindow()
{
// 如果窗口正在旋转,直接返回;
if (m_isRoratingWindow)
{
return;
}
m_isRoratingWindow = true;
m_nextPageIndex = (currentIndex() + 1) >= count() ? 0 : (currentIndex() + 1);
QPropertyAnimation *rotateAnimation = new QPropertyAnimation(this, "rotateValue");
// 设置旋转持续时间;
rotateAnimation->setDuration(1500);
// 设置旋转角度变化趋势;
rotateAnimation->setEasingCurve(QEasingCurve::InCubic);
// 设置旋转角度范围;
rotateAnimation->setStartValue(0);
rotateAnimation->setEndValue(180);
connect(rotateAnimation, SIGNAL(valueChanged(QVariant)), this, SLOT(repaint()));
connect(rotateAnimation, SIGNAL(finished()), this, SLOT(onRotateFinished()));
// 隐藏当前窗口,通过不同角度的绘制来达到旋转的效果;
currentWidget()->hide();
rotateAnimation->start();
}

// 旋转结束;
void RotateWidget::onRotateFinished()
{
m_isRoratingWindow = false;
setCurrentWidget(widget(m_nextPageIndex));
repaint();
}
/ 绘制旋转效果;
void RotateWidget::paintEvent(QPaintEvent *event)
{
if (m_isRoratingWindow)
{
// 小于 90 度时;
int rotateValue = this->property("rotateValue").toInt();
if (rotateValue <= 90)
{
QPixmap rotatePixmap(currentWidget()->size());
currentWidget()->render(&rotatePixmap);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QTransform transform;
transform.translate(width() / 2, 0);
transform.rotate(rotateValue, Qt::YAxis);
painter.setTransform(transform);
painter.drawPixmap(-1 * width() / 2, 0, rotatePixmap);
}
// 大于 90 度时
else
{
QPixmap rotatePixmap(widget(m_nextPageIndex)->size());
widget(m_nextPageIndex)->render(&rotatePixmap);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QTransform transform;
transform.translate(width() / 2, 0);
transform.rotate(rotateValue + 180, Qt::YAxis);
painter.setTransform(transform);
painter.drawPixmap(-1 * width() / 2, 0, rotatePixmap);
}
}
else
{
return QStackedWidget::paintEvent(event);
}
}

QPropertyAnimation动画类

QPropertyAnimation *rotateAnimation = new QPropertyAnimation(this, "rotateValue")

  • rotateValue就是这个动画的属性,我们这个动画中变化的就是旋转值,也就是旋转角度。这个属性名完全自己起,也可以改成rotateAngle等等,或者说想做一个平移的动画,也可以取一个moveDist等名字。

下面这一串就是标准的一套动画流程

1
2
3
4
5
6
7
8
9
// 设置旋转持续时间;
rotateAnimation->setDuration(1000);
// 设置旋转角度变化趋势;
rotateAnimation->setEasingCurve(QEasingCurve::InCubic);
// 设置旋转角度范围;
rotateAnimation->setStartValue(0);
rotateAnimation->setEndValue(180);
//开始动画
rotateAnimation->start();

paintEvent绘图事件

1
2
3
4
5
6
7
8
9
10
#include <QtWidgets/QApplication>
#include "rotatewidget.h"

int main(int argc, char *argv[])
{
QApplication a(argc, argv);
RotateWidget w;
w.show();
return a.exec();
}

我们main函数得知,最开始显示的窗口就是RotateWidget。在实例化一个RotateWidget类后,进行了标题栏的初始化工作,然后开始执行w.show()显示,但是此时窗口是不显示的。这是因为我们在RotateWidget的构造函数中进行了设置不显示窗口。

1
2
3
this->setWindowFlags(Qt::FramelessWindowHint | 
Qt::WindowStaysOnTopHint |
Qt::WindowMinimizeButtonHint);

当运行到return a.exec()时,Qt 会自动调用void RotateWidget::paintEvent()。此时开始正式绘制窗口,但是因为我们还没哟点击登录页面的网络设置按钮,所以m_isRoratingWindow=0。会调用父类的绘图事件,QStackedWidget::paintEvent(),最后也就是BaseWindow::paintEvent()。会将登录页面先绘制出来。

当我们点击网络设置按钮时,m_isRoratingWindow=1开始绘制旋转画面。

系统初始化

x86 架构概述

CPU(Central Processing Unit):中央处理器,计算机所有设备都围绕它展开工作。

  • 运算单元:只管算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。
  • 数据单元:运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了数据单元。数据单元包括 CPU 内部的缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。
  • 控制单元:有了放数据的地方,也有了算的地方,还需要有个指挥到底做什么运算的地方,这就是控制单元。控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。

内存(Memory):CPU 本身不能保存大量数据,许多复杂的计算需要将中间结果保存下来就必须用到内存。

总线(Bus):CPU 和其他设备连接,就靠总线,其实就是主板上密密麻麻的集成电路,这些东西组成了 CPU 和其他设备的高速通道。

  • 地址总线:传输地址数据(我想拿内存中哪个位置的数据)
  • 数据总线:传输真正的数据

总线就像 CPU 和内存之间的高速公路,总线多少位就类似高速公路多少个车道,但两种总线的位数意义不同。

地址总线的位数决定了访问地址范围有多广,数据总线位数决定了一次能拿多少数据进来。那么 CPU 中总线的位数有没有标准呢?如果没有标准,那操作系统作为软件就很难办了,因为软件层没办法实现通用的运算逻辑。早期每家公司的 CPU 架构都不同,后来历史将 x86 平台推到了开放,统一,兼容的位置。

8086 架构图

数据单元: 8086 处理器内部共有 8 个 16 位的通用寄存器,分别是 数据寄存器(AX、BX、CX、DX)、指针寄存器(SP、BP)、变址寄存器(SI、DI)。其中 AX、BX、CX、DX 可以分成两个 8 位的寄存器来使用,分别是 AH、AL、BH、BL、CH、CL、DH、DL,其中 H 就是 High(高位),L 就是 Low(低位)的意思。

控制单元: IP 寄存器(Instruction Pointer Register)就是指令指针寄存器,它指向代码段中下一条指令的位置。CPU 会根据它来不断地将指令从内存的代码段中,加载到 CPU 的指令队列中,然后交给运算单元去执行。

如果需要切换进程呢?每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个 16 位的段寄存器,分别是 CS、DS、SS、ES。

其中,CS 就是代码段寄存器(Code Segment Register),通过它可以找到代码在内存中的位置;DS 是数据段的寄存器(Data Segment Register),通过它可以找到数据在内存中的位置。SS 是栈寄存器(Stack Register)。栈是程序运行中一个特殊的数据结构,数据的存取只能从一端进行,秉承后进先出的原则。ES是扩展段寄存器(Extra Segment Register)顾名思义。

如果 CPU 运算中需要加载内存中的数据,需要通过 DS 找到内存中的数据,加载到通用寄存器中,应该如何加载呢?对于一个段,有一个起始的地址,而段内的具体位置,我们称为偏移量(Offset)。在 CS 和 DS 中都存放着一个段的起始地址。代码段的偏移量在 IP 寄存器中数据段的偏移量会放在通用寄存器中。因为段寄存器都是 16 位的,而地址总线是 20 位的,所以通过 *起始地址 16+ 偏移量 的方式,将寻址位数都变成 20 位,也就是将 CS 和 DS 的值左移 4 位。

对于只有 20 位地址总线的 8086 来说,寻址空间最大也就是$2^{20}=1\text{M}$,超过这个位置就访问不到了,一个段因为偏移量只有 16 位,所以一个段最大是$2^{16}=64\text{k}$。

32 位处理器

随着计算机发展,内存越来越大,总线也越来越宽。在 32 位处理器中,有 32 根地址总线,可以访问 $2^{32}=4\text{G}$ 的内存。使用原来的模式肯定不行了,但是又不能完全抛弃原来的模式,因为这个架构是开放的。那么在开发架构的基础上如何保持兼容呢?

首先,通用寄存器有扩展,可以将 8 个 16 位的扩展到 8 个 32 位的,但是依然可以保留 16 位的和 8 位的使用方式。其中,指向下一条指令的指令指针寄存器 IP,就会扩展成 32 位的,同样也兼容 16 位的。

段寄存器改动较大,新的段寄存器都改成了 32 位的,每个寄存器又分为段描述符缓存器(Segment Descriptor),和段选择子寄存器(Selector) ,现在的段寄存器不在是段的起始地址,段的起始地址保存在表格一样的段描述符缓冲器中,段选择子寄存器保存地址在段描述符缓存器中的哪一项。这样,将一个从段寄存器直接拿到的段起始地址,就变成了先间接地从段寄存器找到表格中的一项,再从表格中的一项中拿到段起始地址。

虽然现在的这种模式和之前的模式不兼容,但是后面这种模式灵活的非常高,可以保持一直兼容下去。在 32 位的系统架构下,将前一种模式称为实模式(Real Pattern),后一种模式称为保护模式(Protected Pattern) 。当系统刚刚启动的时候,CPU 是处于实模式的,这个时候和原来的模式是兼容的。也就是说,哪怕你买了 32 位的 CPU,也支持在原来的模式下运行。

汇编命令学习
mov,
call, jmp, int, ret, add, or, xor, shl, shr, push, pop, inc, dec, sub, cmp。

BIOS 与 BootLoader

BIOS:基本输入输出系统

ROM:只读存储器

RAM:随机存取存储器

在我们按下电脑电源键的那一刻,主板就加电了,CPU 就要开始执行指令了,但是刚开始操作系统都没,CPU 执行什么指令呢?这就有了BIOS,它相当于一个指导手册,告诉 CPU 接下来要干啥。

刚开机时,系统初始化代码从 ROM 读取,将 CS 设置为 0xFFFF,将 IP 设置为 0x0000,所以第一条指令就会指向 0xFFFF0,初始化完成后确定访问指令位置。

接下来 BIOS 会检查各个硬件是否正常,检测内容显卡等关键部件的存在于工作状态,设备初始化,执行系统 BIOS 进行系统检测,更新 CMOS 中的扩展系统配置数据 ESCD。这期间也会建立中断向量表和中断服务程序,因为要使用键盘鼠标都需要中断进行。

下一步 BIOS 就得要找操作系统了,操作系统一般安装在硬盘上,但是 BIOS 得先找到启动盘,启动盘一般安装在第一个扇区,占 512 字节,会包含启动的相关代码。在 Linux 中,可以通过Grub2配置这些代码。

1
grub2-mkconfig -o /boot/grub2/grub.cfg

grub2第一个要安装的就是boot.img。它由 boot.S编译而成,一共 512 字节,正式安装到启动盘的第一个扇区。这个扇区通常称为MBR(Master Boot Record,主引导记录 / 扇区)。

BIOS 完成任务后,会将 boot.img 从硬盘加载到内存中的 0x7c00来运行。

由于 512 个字节实在有限,boot.img 做不了太多的事情。它能做的最重要的一个事情就是加载grub2 的另一个镜像 core.img

core.imglzma_decompress.imgdiskboot.imgkernel.img 和一系列的模块组成,功能比较丰富,能做很多事情。

boot.img 先加载的是 core.img 的第一个扇区。如果从硬盘启动的话,这个扇区里面是diskboot.img,对应的代码是 diskboot.S

boot.img 将控制权交给 diskboot.img 后,diskboot.img 的任务就是将core.img 的其他部分加载进来,先是解压缩程序 lzma_decompress.img,再往下是 kernel.img,最后是各个模块module对应的映像。这里需要注意,它不是 Linux 的内核,而是grub 的内核。

在这之前,我们所有遇到过的程序都非常非常小,完全可以在实模式下运行,但是随着我们加载的东西越来越大,实模式这1M 的地址空间实在放不下了,所以在真正的解压缩之前,lzma_decompress.img 做了一个重要的决定,就是调用 real_to_prot,切换到保护模式,这样就能在更大的寻址空间里面,加载更多的东西。

BIOS将加载程序从硬盘的引导扇区加载到指定位置,再跳转到指定位置,将控制权转交给加载程序。加载程序将操作系统代码读取到内存,并将控制权转到操作系统。

Q:BIOS-操作系统,中间经过加载程序。为何不直接读取?
A:磁盘文件系统多种多样,硬盘出厂时不能限制只能用一种文件系统,而 BIOS 也不能加上识别所有文件系统的代码。所有为了灵活性只读取磁盘的一块,由加载程序来识别磁盘的文件系统。

切换到保护模式后,将会做以下这些事,大多数都与内存访问方式有关。

首先启动分段,就是在内存里面建立段描述符表,将寄存器里面的段寄存器变成段选择子,指向某个段描述符,这样就能实现不同进程的切换了。

接着是启动分页。能够管理的内存变大了,就需要将内存分成相等大小的块。

打开 Gate20,也就是第 21 根地址线的控制线。因为在实模式 8086 下,一共就 20 根地址线,最大访问1M的地址空间。切换保护模式的函数DATA32 call real_to_prot会打开Gate A20

现在好了,有的是空间了。接下来我们要对压缩过的 kernel.img 进行解压缩,然后跳转到 kernel.img 开始运行。

内核初始化

  • start_kernel()
    • INIT_TASK(init_task)
    • trap_init()
    • mm_init()
    • sched_init()
    • rest_init()
      • kernel_thread(kernel_init, NULL,CLONE_FS)
      • kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)

内核的启动从入口函数start_kernel() 开始。在 init/main.c 文件中,start_kernel 相当于内核的 main 函数。打开这个函数,我们会发现,里面是各种各样初始化函数 XXXX_init

在操作系统里面,先要有个创始进程,有一行指令 set_task_stack_end_magic(&init_task)。这里面有一个参数 init_task,它的定义是 struct task_struct init_task = INIT_TASK(init_task)。它是系统创建的第一个进程,我们称为0号进程。这是唯一一个没有通过fork 或者kernel_thread 产生的进程,是进程列表的第一个。

trap_init()里设置了很多**中断门 (Interrupt Gate)**处理各种中断。

mm_init()初始化内存管理模块,sched_init()初始化调度模块。

vfs_caches_init() 会用来初始化基于内存的文件系统 rootfs。在这个函数里面,会调用 mnt_init()->init_rootfs()。这里面有一行代码,register_filesystem(&rootfs_fs_type)。在 VFS 虚拟文件系统里面注册了一种类型,我们定义为 struct file_system_type rootfs_fs_type。为了兼容各种各样的文件系统,我们需要将文件的相关数据结构和操作抽象出来,形成一个抽象层对上提供统一的接口,这个抽象层就是 VFS(Virtual File System),虚拟文件系统。

最后start_kernel()调用rest_init()来做其他方面的初始化,如初始化 1 号进程,内核态与用户态转化等。

rest_init 的第一大工作是,用 kernel_thread(kernel_init, NULL, CLONE_FS)创建第二个进程,这个是1 号进程。这对操作系统意义非凡,因为他将运行第一个用户进程,一旦有了用户进程,运行模式也将发生改变,之前所有资源都是给一个进程用,现在有了用户进程,就会出现抢夺资源的现象。资源也分核心和非核心资源,具有不同权限的进程可以获取不同的资源。x86提供了分层的权限机制,分成四个Ring,越往里权限越高。

操作系统很好地利用了这个机制,将能够访问关键资源的代码放在 Ring0,我们称为内核态(Kernel Mode);将普通的程序代码放在 Ring3,我们称为用户态(User Mode)。

继续探究kernel_thread()这个函数,它的一个参数有一个函数kernel_init,在这个函数里会调用kernel_init_freeable(),里面有这样一段代码

1
2
if (!ramdisk_execute_command)
ramdisk_execute_command = "/init";

先不管ramdisk 是啥,我们回到 kernel_init 里面。这里面有这样的代码块:

1
2
3
4
5
6
7
8
9
10
if (ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command);
....
}
....
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;

我们可以发现,1 号进程运行的是一个文件,如果我们打开run_init_process函数,会发现它调用的是do_execve

前面讲系统调用的时候,execve 是一个系统调用,它的作用是运行一个执行文件。加一个 do_ 的往往是内核系统调用的实现。没错,这就是一个系统调用,它会尝试运行 ramdisk 的“/init”,或者普通文件系统上的“/sbin/init”“/etc/init”“/bin/init”“/bin/sh”。不同版本的 Linux 会选择不同的文件启动,但是只要有一个起来了就可以。

1
2
3
4
5
6
7
static int run_init_process(const char *init_filename)
{
argv_init[0] = init_filename;
return do_execve(getname_kernel(init_filename),
(const char __user *const __user *)argv_init,
(const char __user *const __user *)envp_init);
}

如何利用执行 init 文件的机会,从内核态回到用户态呢?

我们从系统调用的过程可以得到启发,“用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态”,然后接着运行。而咱们刚才运行init,是调用 do_execve,正是上面的过程的后半部分,从内核态执行系统调用开始。

do_execve->do_execveat_common->exec_binprm->search_binary_handler,这里面会调用这段内容:

1
2
3
4
5
6
7
8
int search_binary_handler(struct linux_binprm *bprm)
{
......
struct linux_binfmt *fmt;
......
retval = fmt->load_binary(bprm);
......
}

也就是说,我要运行一个程序,需要加载这个二进制文件,这就是我们常说的项目执行计划书。它是有一定格式的。Linux 下一个常用的格式是 ELF(Executable and Linkable Format,可执行与可链接格式)。于是我们就有了下面这个定义:

1
2
3
4
5
6
7
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};

这其实就是先调用 load_elf_binary,最后调用 start_thread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
set_user_gs(regs, 0);
regs->fs = 0;
regs->ds = __USER_DS;
regs->es = __USER_DS;
regs->ss = __USER_DS;
regs->cs = __USER_CS;
regs->ip = new_ip;
regs->sp = new_sp;
regs->flags = X86_EFLAGS_IF;
force_iret();
}
EXPORT_SYMBOL_GPL(start_thread);

struct pt_regs,看名字里的 register,就是寄存器啊!这个结构就是在系统调用的时候,内核中保存用户态运行上下文的,里面将用户态的代码段 CS设置为 __USER_CS,将用户态的数据段 DS 设置为 __USER_DS,以及指令指针寄存器 IP栈指针寄存器 SP。这里相当于补上了原来系统调用里,保存寄存器的一个步骤。

最后的 iret 是干什么的呢?它是用于从系统调用中返回。这个时候会恢复寄存器。从哪里恢复呢?按说是从进入系统调用的时候,保存的寄存器里面拿出。好在上面的函数补上了寄存器。CS 和指令指针寄存器 IP 恢复了,指向用户态下一个要执行的语句。DS 和函数栈指针 SP 也被恢复了,指向用户态函数栈的栈顶。所以,下一条指令,就从用户态开始运行了。

init 终于从内核到用户态了。一开始到用户态的是 ramdisk 的 init,后来会启动真正根文件系统上的 init,成为所有用户态进程的祖先。

为什么会有 ramdisk 这个东西呢?还记得上一节咱们内核启动的时候,配置过这个参数:

1
initrd16 /boot/initramfs-3.10.0-862.el7.x86_64.img

就是这个东西,这是一个基于内存的文件系统。为啥会有这个呢?

是因为刚才那个 init 程序是在文件系统上的,文件系统一定是在一个存储设备上的,例如硬盘。Linux 访问存储设备,要有驱动才能访问。如果存储系统数目很有限,那驱动可以直接放到内核里面,反正前面我们加载过内核到内存里了,现在可以直接对存储系统进行访问。

但是存储系统越来越多了,如果所有市面上的存储系统的驱动都默认放进内核,内核就太大了。这该怎么办呢?

我们只好先弄一个基于内存的文件系统。内存访问是不需要驱动的,这个就是 ramdisk。这个时候,ramdisk 是根文件系统。

然后,我们开始运行 ramdisk 上的 /init。等它运行完了就已经在用户态了。/init 这个程序会先根据存储系统的类型加载驱动,有了驱动就可以设置真正的根文件系统了。有了真正的根文件系统,ramdisk上的 /init 会启动文件系统上的 init

接下来就是各种系统的初始化。启动系统的服务,启动控制台,用户就可以登录进来了。

至此,用户态进程有了一个祖宗,那内核态的进程呢?这就是rest_init接下来要做的是,创建 2 号线程

kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)又一次使用 kernel_thread 函数创建进程。这里的函数 kthreadd,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。

系统调用

Linux 提供了glibc这个库封装了系统调用,方便用户使用。那么在打开一个文件时,glibc是如何调用内核的open的呢?

glibc 的源代码中,有个文件syscalls.list,里面列着所有 glibc 的函数对应的系统调用,就像下面这个样子:

1
2
# File name Caller  Syscall name    Args    Strong name Weak names
open - open Ci:siv __libc_open __open open

另外,glibc 还有一个脚本 make-syscall.sh,可以根据上面的配置文件,对于每一个封装好的系统调用,生成一个文件。这个文件里面定义了一些宏,例如 #define SYSCALL_NAME open

glibc 还有一个文件 syscall-template.S,使用上面这个宏,定义了这个系统调用的调用方式。

对于任何一个系统调用,会调用DO_CALL。这也是一个宏,这个宏 32 位和 64 位的定义是不一样的。

32 位系统调用过程

i386 目录下的sysdep.h 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Linux takes system call arguments in registers:
syscall number %eax call-clobbered
arg 1 %ebx call-saved
arg 2 %ecx call-clobbered
arg 3 %edx call-clobbered
arg 4 %esi call-saved
arg 5 %edi call-saved
arg 6 %ebp call-saved
......
*/
#define DO_CALL(syscall_name, args)
PUSHARGS_##args
DOARGS_##args
movl $SYS_ify (syscall_name), %eax;
ENTER_KERNEL
POPARGS_##args

这里,我们将请求参数放在寄存器里面,根据系统调用的名称,得到系统调用号,放在寄存器 eax 里面,然后执行 ENTER_KERNEL

1
# define ENTER_KERNEL int $0x80

ENTER_KERNEL就是一个软中断,通过它可以陷入 (trap) 内核。

在内核启动的时候,还记得有一个 trap_init(),这是一个软中断的陷入门。当接到一个系统调用时,trap_init()就会调用entry_INT80_32

通过 pushSAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构里面,然后调用 do_syscall_32_irqs_on

64 位系统调用过程

前提

本文主要涉及 VSCode 的相关配置,编译及调试工具需要提前安装好。

  • 已经安装好riscv-toolchain,包括riscv64-unknown-elf-gccriscv64-unknown-elf-gdb
  • 已经安装好qemu,包括riscv32-softmmu,riscv32-linux-user,riscv64-softmmu,riscv64-linux-user
  • 已经安装好g++,gdb

调试流程简介

对于我这样的新手,要调试一个项目源码最怕的就是开始,也就是怎么能把项目跑起来。

我们以一个简单的test项目,看看在 VSCode 里怎么跑起来。

拿到源码后,将其以文件夹形式,加入到 VSCode 中,文件 - 打开文件夹 - 选择 test 项目文件夹。项目就会在 VSCode 中打开,但是此时我们还无法编译运行,我们需要在 VSCode 上
构建出一个 C 语言的编译与调试环境。

首先得安装一个插件C/C++,打开插件中心Ctrl+Shit+X,搜索,安装。

然后输入F5,会弹出对话框,选择C++(GDB),继续选择g++。VSCode 会自动创建.vscode文件夹,已经两个文件launch.jsontasks.json

launch.json用来配置调试环境,tasks.json主要用来配置编译环境,当然也可以配置其他任务。task.json里配置的每个任务其实就相当于多开一个控制台。

配置tasks.json

因为我们先要编译源码,生成.out或者.exe文件,才能调试,所以先进行编译任务配置。

自动生成的文件是个配置模板,我们可以根据自己的实际情况进行配置,也有一部分可以保持默认。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// tasks.json
{
// https://code.visualstudio.com/docs/editor/tasks
"version": "2.0.0",
"tasks": [
{
// 任务的名字,注意是大小写区分的
//会在launch中调用这个名字
"label": "C/C++: g++ build active file",
// 任务执行的是shell
"type": "shell",
// 命令是g++
"command": "g++",
//g++ 后面带的参数
"args": [
"'-Wall'",
"-g", // 生成调试信息,否则无法进入断点
"'-std=c++17'", //使用c++17标准编译
"'${file}'", //当前文件名
"-o", //对象名,不进行编译优化
"'${fileBasenameNoExtension}.exe'", //当前文件名(去掉扩展名)
],
}
]
}

如果项目是通过 Makefile 编译的,那就更加简单,只需要配置一个任务即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"version": "2.0.0",
"tasks": [
{
//任务的名字方便执行
"label": "Make Project",
"type": "shell",
"command": "make",
"args":[
//8线程编译
"-j8",
],
},
]
}

运行该任务时就会执行make命令进行编译。

配置launch.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// launch.json

{
"version": "0.2.0",
"configurations": [
{
//调试任务的名字
"name": "g++ - Build and debug active file",
//在launch之前运行的任务名,这个名字一定要跟tasks.json中的任务名字大小写一致
"preLaunchTask": "C/C++: g++ build active file",
"type": "cppdbg",
"request": "launch",
//需要运行的是当前打开文件的目录中,
//名字和当前文件相同,但扩展名为exe的程序
"program": "${fileDirname}/${fileBasenameNoExtension}.exe",
"args": [],
// 选为true则会在打开控制台后停滞,暂时不执行程序
"stopAtEntry": false,
// 当前工作路径:当前文件所在的工作空间
"cwd": "${workspaceFolder}",
"environment": [],
// 是否使用外部控制台
"externalConsole": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
]
}]
}

运行

经过以上配置后,我们打开main.cpp文件,在cout处打一个断点,按F5,即可编译,运行,调试。一定要打开main.cpp文件,不能随便打开文件就开始哦。因为我们在配置时使用了一些预定义,比如${file}表示当前文件,所以只有打开需要调试的文件才能开始。

程序将会在cout语句停下来。

我们可以注意一下界面下方的控制台,可以更直观了解launch.jasontasks.jason

右边的框,就是我们在tasks.jason中配置的任务,左边的框就是我们在tasks.jasoncommand以及args的内容,他就是帮我们提前写好编译的选项。然后在 shell 中运行。

编译调试 RISC-V 程序

了解以上这些,就可以按需配置所需的环境了。我们还是从tasks.jason开始。因为开发用的电脑是x86的,所以先要编译出riscv的程序,再用模拟器模拟出rsicv的环境,然后在模拟的环境中运行程序,最后才能开始调试。

假设已经安装好开头所提到的工具。首先配置tasks.jason

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
"version": "2.0.0",
"tasks": [
{
// 编译当前代码
"type": "shell",
"label": "C/C++(RISCV): Build active file",
// 编译器的位置
"command": "/opt/riscv/bin/riscv64-unknown-elf-g++",
"args": [
"-Wall", // 开启所有警告
"-g", // 生成调试信息s
"${file}",
"-o",
"${workspaceFolder}/debug/${fileBasenameNoExtension}" // 我选择将可执行文件放在debug目录下
],
// 当前工作路径:执行当前命令时所在的路径
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$gcc"
]
},
{
// 启动qemu供调试器连接
"type": "shell",
"label": "Run Qemu Server(RISCV)",
"dependsOn": "C/C++(RISCV): Build active file",
"command": "qemu-system-riscv64",
"args": [
"-g",
"65500", // gdb端口,自己定义
"${workspaceFolder}/debug/${fileBasenameNoExtension}"
],
},
{
// 有时候qemu有可能没法退出,故编写一个任务用于强行结束qemu进程
"type": "shell",
"label": "Kill Qemu Server(RISCV)",
"command": "ps -C qemu-riscv64 --no-headers | cut -d \\ -f 1 | xargs kill -9",
}
]
}

tasks.jason是可以配置多个任务的,第一个任务用来编译成riscv架构下的程序,第二个任务用来启动 qemu,让程序在 qemu 上运行起来。

第一个任务中,command就是配置编译器riscv64-unkonown-elf-gcc的属性,第二个任务中,command是配置 qemu 模拟器qemu-system-riscv32的属性。第三个任务中,用来配置结束 qemu 模拟器的命令。

接下来配置launch.jason

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"version": "0.2.0",
"configurations": [
{
"name": "C/C++(RISCV) - Debug Active File",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/debug/${fileBasenameNoExtension}",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "为 gdb 启用整齐打印",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
// RISC-V工具链中的gdb
"miDebuggerPath": "/opt/riscv/bin/riscv64-unknown-elf-gdb",
// 这里需要与task.json中定义的端口一致
"miDebuggerServerAddress": "localhost:65500"
}
]
}

我们在配置x86下的调试环境时,launch.jason中有个"preLaunchTask": "C/C++: g++ build active file",属性,这个属性的目的是在启动调试之前,先执行任务名字为"C/C++: g++ build active file"任务,也是就编译的任务。

因为启动 qemu 会导致阻塞,所以这里没有加preLaunchTask,在启动调试之前,先把 qemu 运行起来。输入Ctrl+Shift+P,打开 VSCode 命令行。输入Run Task

点击第一个,选择任务,我们可以看到出现的三个任务就是我们在tasks.jason中配置的三个任务。选择第一个 Build,编译出程序,再重复操作,选择第三个执行 QEMU 任务。

预定义变量

官网

简介

为了防止出现因多个程序同时访问一个共享资源而引发的一系列问题,我们需要一种方法,它可以通过生成并使用令牌来授权,在任一时刻只能有一个执行线程访问代码的临界区域。临界区域是指执行数据更新的代码需要独占式地执行。而信号量就可以提供这样的一种访问机制,让一个临界区同一时间只有一个线程在访问它,也就是说信号量是用来调协进程对共享资源的访问的。

信号量是一个特殊的变量,程序对其访问都是原子操作,且只允许对它进行等待(即P) 和发送(即V) 信息操作。最简单的信号量是只能取 0 和 1 的变量,这也是信号量最常见的一种形式,叫做二进制信号量。而可以取多个正整数的信号量被称为通用信号量。这里主要讨论二进制信号量。

由于信号量只能进行两种操作等待和发送信号,即 P(sv) 和 V(sv),他们的行为是这样的:

P(sv):如果sv的值大于零,就给它减 1;如果它的值为零,就挂起该进程的执行

V(sv):如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因等待sv而挂起,就给它加 1.

举个例子,就是两个进程共享信号量sv,一旦其中一个进程执行了P(sv)操作,它将得到信号量,并可以进入临界区,使sv减 1。而第二个进程将被阻止进入临界区,因为当它试图执行P(sv)时,sv为 0,它会被挂起以等待第一个进程离开临界区域并执行V(sv)释放信号量,这时第二个进程就可以恢复执行。

本文代码同步在这里

相关函数

Linux 提供了一组精心设计的信号量接口来对信号进行操作,它们不只是针对二进制信号量,下面将会对这些函数进行介绍,但请注意,这些函数都是用来对成组的信号量值进行操作的。它们声明在头文件 sys/sem.h 中。

semget()

它的作用是创建一个新信号量或取得一个已有信号量,原型为:

1
int semget(key_t key, int num_sems, int sem_flags);
  • key是整数值(唯一非零),不相关的进程可以通过它访问一个信号量,它代表程序可能要使用的某个资源,程序对所有信号量的访问都是间接的,程序先通过调用semget()函数并提供一个键,再由系统生成一个相应的信号标识符(semget()函数的返回值),只有semget()函数才直接使用信号量键,所有其他的信号量函数使用由semget()函数返回的信号量标识符。如果多个程序使用相同的key值,key将负责协调工作。

  • num_sems指定需要的信号量数目,它的值几乎总是 1。

  • sem_flags是一组标志,当想要当信号量不存在时创建一个新的信号量,可以和值IPC_CREAT做按位或操作。设置了IPC_CREAT标志后,即使给出的键是一个已有信号量的键,也不会产生错误。而IPC_CREAT | IPC_EXCL则可以创建一个新的,唯一的信号量,如果信号量已存在,返回一个错误。

semget()函数成功返回一个相应信号标识符(非零),失败返回-1.

semop()

它的作用是改变信号量的值,原型为:

1
int semop(int sem_id, struct sembuf *sem_opa, size_t num_sem_ops);
  • sem_id是由semget()返回的信号量标识符,sembuf结构的定义如下:

    1
    2
    3
    4
    5
    6
    7
    struct sembuf{
    short sem_num; // 除非使用一组信号量,否则它为0
    short sem_op; // 信号量在一次操作中需要改变的数据,通常是两个数,一个是-1,即 P(等待)操作,
    // 一个是+1,即V(发送信号)操作。
    short sem_flg; // 通常为 SEM_UNDO,使操作系统跟踪信号,
    // 并在进程没有释放该信号量而终止时,操作系统释放信号量
    };
  • num_sem_ops:操作sops中的操作个数,通常取值为 1

semctl()

该函数用来直接控制信号量信息,它的原型为:

1
int semctl(int sem_id, int sem_num, int command, ...);
  • 如果有第四个参数,它通常是一个union semum结构,定义如下:

    1
    2
    3
    4
    5
    union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *arry;
    };
  • 前两个参数与前面一个函数中的一样,command通常是下面两个值中的其中一个

  • SETVAL:用来把信号量初始化为一个已知的值。p 这个值通过 union semun 中的 val 成员设置,其作用是在信号量第一次使用前对它进行设置。

  • IPC_RMID:用于删除一个已经无需继续使用的信号量标识符。

简介

消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。

每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。我们可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。

本文代码同步在这里

相关函数

msgget()

该函数用来创建和访问一个消息队列。它的原型为:

1
int msgget(key_t, key, int msgflg);
  • key:与其他的 IPC 机制一样,程序必须提供一个键来命名某个特定的消息队列。
  • msgflg是一个权限标志,表示消息队列的访问权限,它与文件的访问权限一样。msgflg可以与IPC_CREAT做或操作,表示当 key 所命名的消息队列不存在时创建一个消息队列,如果 key 所命名的消息队列存在时,IPC_CREAT标志会被忽略,而只返回一个标识符。

它返回一个以key命名的消息队列的标识符(非零整数),失败时返回-1.

msgsnd()

该函数用来把消息添加到消息队列中。它的原型为:

1
int msgsend(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg);
  • msgid是由msgget函数返回的消息队列标识符。

  • msg_ptr是一个指向准备发送消息的指针,但是消息的数据结构却有一定的要求,指针msg_ptr所指向的消息结构一定要是以一个长整型成员变量开始的结构体,接收函数将用这个成员来确定消息的类型。所以消息结构要定义成这样:

    1
    2
    3
    4
    struct my_message {
    long int message_type;
    /* The data you wish to transfer */
    };
  • msg_szmsg_ptr指向的消息的长度

  • msgflg 用于控制当前消息队列满或队列消息到达系统范围的限制时将要发生的事情

  • 如果调用成功,消息数据的副本将被放到消息队列中,并返回0,失败时返回-1.

msgrcv()

该函数用来从一个消息队列获取消息,它的原型为

1
int msgrcv(int msgid, void *msg_ptr, size_t msg_st, long int msgtype, int msgflg);
  • 前三个参数参照前面的解释
  • msgtype 可以实现一种简单的接收优先级。如果msgtype0,就获取队列中的第一个消息。如果它的值大于零,将获取具有相同消息类型的第一个信息。如果它小于零,就获取类型等于或小于msgtype的绝对值的第一个消息。
  • msgflg 用于控制当队列中没有相应类型的消息可以接收时将发生的事情。
  • 调用成功时,该函数返回放到接收缓存区中的字节数,消息被复制到由msg_ptr指向的用户分配的缓存区中,然后删除消息队列中的对应消息。失败时返回-1

msgctl()

该函数用来控制消息队列,它与共享内存的shmctl函数相似,它的原型为:

1
int msgctl(int msgid, int command, struct msgid_ds *buf);
  • msgid同上

  • command是将要采取的动作,它可以取3个值:

    • IPC_STAT:把msgid_ds结构中的数据设置为消息队列的当前关联值,即用消息队列的当前关联值覆盖msgid_ds的值。
    • IPC_SET:如果进程有足够的权限,就把消息列队的当前关联值设置为msgid_ds结构中给出的值
    • IPC_RMID:删除消息队列
  • buf是指向msgid_ds结构的指针,它指向消息队列模式和访问权限的结构。msgid_ds结构至少包括以下成员:

    1
    2
    3
    4
    5
    6
    struct msgid_ds
    {
    uid_t shm_perm.uid;
    uid_t shm_perm.gid;
    mode_t shm_perm.mode;
    };
  • 成功时返回 0,失败时返回 -1.

Demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
//msgsnd
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/msg.h>

#define MAX_TXT 512

struct msg_st
{
long int msg_type;
char msg[MAX_TXT];
};

int main()
{
struct msg_st message;
int msgid = 1;
char buffer[BUFSIZ];
key_t msgKey = ftok("./msgsnd.c", 0);
msgid = msgget(msgKey, 0666 | IPC_CREAT);

if (msgid == -1)
{
fprintf(stderr, "masget failed error: %d\n", errno);
exit(EXIT_FAILURE);
}
while (1)
{
printf("Enter some text: \n");
fgets(buffer, BUFSIZ, stdin);
message.msg_type = 1; // 注意 2
strcpy(message.msg, buffer);

// 向队列里发送数据
if (msgsnd(msgid, (void *)&message, MAX_TXT, 0) == -1)
{
fprintf(stderr, "msgsnd failed\n");
exit(EXIT_FAILURE);
}

// 输入 end 结束输入
if (strncmp(buffer, "end", 3) == 0)
{
break;
}

sleep(1);
}

exit(EXIT_SUCCESS);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//msgrcv
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <sys/msg.h>

#define MAX_TXT 512

struct msg_st
{
long int msg_type;
char msg[MAX_TXT];
};

int main()
{
struct msg_st message;
int msgid = 1;
long int msgtype = 0;
key_t msgKey = ftok("./msgsnd.c", 0);
msgid = msgget(msgKey, 0666 | IPC_CREAT);

if (msgid == -1)
{
fprintf(stderr, "masget failed error: %d\n", errno);
exit(EXIT_FAILURE);
}
while (1)
{
if (msgrcv(msgid, (void *)&message, BUFSIZ, msgtype, 0) == -1)
{
fprintf(stderr, "msgsnd failed\n");
exit(EXIT_FAILURE);
}

printf("You wrote: %s\n", message.msg);

if (strncmp(message.msg, "end", 3) == 0)
{
break;
}
}

exit(EXIT_SUCCESS);
}

运行结果

本来不想写这一篇的,安装 VSCode 时随便搜一下就 OK 了,但是因为 APT 源中没有 VSCode,所以需要找下载网址,几次的安装经历下来,找下载网址也经历了一番折腾。今天又要安装一遍,就顺手记录一下吧。以后翻自己记录总比翻全网记录方便。

官方文档

其实最完备安装教程在官方文档里。本文也算是对官方文档的一个翻译版吧。

基于 Debian 和 Ubuntu 的发行版

如果下载了.deb 安装包,那么只需要一个命令就可以完成安装了。

1
sudo apt install ./<file>.deb

无奈的是,我需要在开发机安装,无法下载安装包,但是我又不想用ftp传来传去,要是apt能完成,绝不单独下载安装包。

可以使用以下脚本手动安装存储库和密钥

1
wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > packages.microsoft.gpg
1
sudo install -o root -g root -m 644 packages.microsoft.gpg /etc/apt/trusted.gpg.d/
1
sudo sh -c 'echo "deb [arch=amd64,arm64,armhf signed-by=/etc/apt/trusted.gpg.d/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" > /etc/apt/sources.list.d/vscode.list'
1
rm -f packages.microsoft.gpg

更新与安装

1
2
3
sudo apt install apt-transport-https
sudo apt update
sudo apt install code # or code-insiders
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×