0%

感觉学的还是有点慢啊,刚刚往后看了一点,感觉后面的知识越来越难,我得赶快点学。

法向量和多物体处理

根据法向量进行表面着色

我们需要计算出表面法线进而进行着色。(法线是垂直于交点处的表面的向量)

法向量的计算使用决策有以下两种情况:

  • 法向量可以有任意长度,这些长度可以附带额外的信息,比如反射啥的
  • 法向量全部进行单位化,此时法向量仅仅代表其法线方向

我们可以看到法线的单位化过程中有开方操作,这个操作可能会花费较多的时间,但是我们仍然保留它,理由有三:

  • 你不应该等到要用的时候再进行单位化,设置条件可能会导致程序更加复杂
  • 我们会经常用到单位化的法向量
  • 法向量的单位化比想象中的简单,根据特定的几何类可以定制不同的单位化策略,如在球体中可以通过除以radius实现法向量的单位化

综上考虑,我们对所有的法向量都进行单位化。

球体的外法线的计算方式也很简单:

1
2
vec(d) = point(P) - point(C)
unit(vec(d)) = vec(d)/radius
image.png

由于我们的单位向量的大小是1也就是说,单位法向量在其他任意方向的分量范围都在[-1,1]内,所以我们可以将其每个分量都映射到[0,1]的区间内,然后将(x,y,z)映射到 (red,green,blue)

我们需要计算出t从而计算出法向量的信息,我们确保球体是在摄像机的前方,所以不必考虑t的负值。当t有多个解的时候,我们只考虑离我们最近的交点(最小的t值),这样我们可以计算出我们的法向量:

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
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
//判断delta值,并计算t(这里计算的是较小的t)
if(discriminant < 0.0){
return -1.0;
}else{
return (-b - std::sqrt(discriminant)) / (2.0*a);
}
}

color ray_color(ray & r){
auto t = hit_sphere(point3(0,0,-1),0.5,r);
//根据t的分量进行上色
if(t > 0.0){
vec3 N = unit_vector(r.at(t) - point3(0,0,-1));
return 0.5*color(N.x()+1,N.y()+1,N.z()+1);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们将程序修改后运行渲染得到了新的图片:

image.png

光线计算的简化

我们查看原本的交点计算方程:

1
2
3
4
5
6
7
8
9
10
11
12
13
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;

if(discriminant < 0.0){
return -1.0;
}else{
return (-b - std::sqrt(discriminant)) / (2.0*a);
}
}

我们注意到b中有一个-2的因子,那么我们考虑将其分离出来,令b = -2h

于是方程可以进行以下变换实现简化:

image.png

同时我们知道向量的自己和自己的点积,等同于向量的平方dot(vec(v),vec(v)) = vec(v)^2

所以我们的程序可以改进成以下形式(没看出改进太多啊):

1
2
3
4
5
6
7
8
9
10
11
12
13
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;
auto discriminant = h*h - a*c;

if(discriminant < 0.0){
return -1.0;
}else{
return (h - std::sqrt(discriminant)) / a;
}
}

可被(光线)击中类的抽象

接下来我们需要考虑当有多个球体的情况,你可能觉得很简单,可以创建一个数组来实现,但实际上有更好的选择——创建一个抽象类,这里我们称其为hittable类。

这个抽象类将接受ray作为参数。同时我们添加一个有效的命中区间(t_min到t_max),这样便于我们对t的计算,只有在有效命中区内,t才被采用,命中才计数。同时我们还要考虑一个问题,那就是我们只需要考虑并计算离我们较近的东西的法线,所以我们需要采取一些方案来计算他们,以下是一个抽象类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

#ifndef RENDER_C___HITTABLE_H
#define RENDER_C___HITTABLE_H

#include "ray.h"

//用于存储光线与物体相交的重要信息
class hit_record{
public:
point3 p;
vec3 normal;
double t;
};

class hittable{
public:
virtual ~hittable() = default;
//纯虚函数,用来实现函数的多态性
virtual bool hit(const ray& r, double ray_min,double ray_max,hit_record& rec) const = 0;
};

#endif //RENDER_C___HITTABLE_H

在此基础上实现一个球体的类:

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

#ifndef RENDER_C___SPHERE_H
#define RENDER_C___SPHERE_H

#include "vec3.h"
#include "hittable.h"

class sphere: public hittable{
public:
sphere(const point3& center, double radius) : center(center), radius(std::fmax(0,radius)) {}
//fmax返回两个浮点数参数较大的一个,fmin同理
bool hit(const ray& r, double ray_min,double ray_max,hit_record& rec) const override{
vec3 oc = center - r.origin(); // override 重写基类的虚函数
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;

auto discriminant = h*h - a*c;
if(discriminant < 0.0){
return false;
}

//解t并进行判断
auto sqrtd = std::sqrt(discriminant);
auto root = (h - sqrtd) / a;
if(root <= ray_min || ray_max <= root){
root = (h + sqrtd) / a;
if(root <= ray_min || ray_max <= root)
return false;
}

rec.t = root;
rec.p = r.at(rec.t);
rec.normal = (rec.p - center) / radius;

return true;
}

private:
point3 center;
double radius;
};


#endif //RENDER_C___SPHERE_H

前表面与后表面

我们先前提到了关于法线的几个决策,现在我们需要决定法线是否应该始终指向外面。当然,我们可以利用法线始终指向外面的方式来进行光线来向的判断,并帮助我们区分前后表面。

image.png

以这张图为例,我们可以通过计算光线方向和法线(始终朝外)的点积,并判断它的正负来实现其方向的判断。如果其点积为正,说明光线与法线同向,照射到的是内表面。如果点积为负,说明光线与法线反向,照射到的是外表面。我们可以通过以下程序实现判断:

1
2
3
4
5
6
7
8
9
10
bool front_face;
if(dot(ray_direction,outward_normal) > 0.0){
// 光线在球体内部
normal = -outward_normal; //实际的法线方向,需要翻转
front_face = false;
}else{
// 光线在球体外部
normal = outward_normal;
front_face = true;
}

我们将其添加到我们的hittable.hsphere中:

1
2
3
4
5
6
7
8
9
10
11
12
13
class hit_record{
public:
point3 p;
vec3 normal;
double t;
bool front_face;

void set_face_normal(const ray& r,const vec3& outward_normal){
//设置交点的法线方向
front_face = dot(r.direction(),outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class sphere : public hittable {
public:
...
bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const {
...

rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r, outward_normal);

return true;
}
...
};

可击中对象的列表

我们之前创建了一个抽象类hittabel.h,用来描述光线可以相交的物体。现在我们再创建一个类来存储具有hittable特性的列表:

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

#ifndef RENDER_C___HITTABLE_LIST_H
#define RENDER_C___HITTABLE_LIST_H

#include "hittable.h"

#include <memory>
#include <vector>

using std::make_shared;
using std::shared_ptr;

class hittable_list : public hittable {
public:
std::vector<shared_ptr<hittable>> objects;
// 创建了一个可击中对象的数组
hittable_list(){}
hittable_list(shared_ptr<hittable> object) {add(object);}

void clear() {objects.clear();}

void add(shared_ptr<hittable> object) {
objects.push_back(object);
}

bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const override{
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_tmax;

for(const auto& object : objects){
if(object->hit(r,ray_tmin,closest_so_far,temp_rec)){
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}
return hit_anything;
}
};

#endif //RENDER_C___HITTABLE_LIST_H

这里有些地方用到了C++的一些特性,需要特别强调一下。

shared_ptr<> 是一个智能指针,被包含在<memory>中,用来指向已经分配的类型,它可以自动管理内存,当对象不再被任何shared_ptr引用时,自动释放内存。我们可以使用make_shared<thing>(...)为数据类型thing创建一个实例,并返回一个shared_ptr<thing>例如:

1
2
3
auto double_ptr = make_shared<double>(0.41);
auto vec3_ptr = make_shared<vec3>(1.1,1.2,1.3);
auto sphere_ptr = make_shared<sphere>(point3(0,0,0),1.0)

还有一个需要注意的用到的特性是std::vector,其被包含在<vector>中,其用来生成一个动态数组,并且支持对其集合仅从指定的操作。例如:

1
2
3
4
5
6
7
8
9
std::vector<shared_ptr<hittable>> objects;
//添加一个值
objects.push_back(object);
//删除所有值
objects.clear();
//数组大小
objects.size();
//访问
objects[]

大概涉及到这些吧,剩下的之后再慢慢了解

常用常量和实用函数

我们设置一个头文件,我们放置一些常用的数学常量和一些常用的头文件,这样方便我们的调取

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

#ifndef RENDER_C___RTWEEKEND_H
#define RENDER_C___RTWEEKEND_H

#include <cmath>
#include <iostream>
#include <limits>
#include <memory>

using std::make_shared;
using std::shared_ptr;

//常量设置
const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;

//实用函数
inline double degree_to_radius(double degrees){
return degrees * pi / 180.0;
}

//常用文件头
#include "color.h"
#include "ray.h"
#include "vec3.h"

#endif //RENDER_C___RTWEEKEND_H

现在我们的rtweekend.h里面已经包含了大多数常用的文件头,

我们现在利用上述新创建文件头,写一个新的main函数:

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
//这里我们修改了一下头文件还有ray_color函数
#include "rtweekend.h"
#include "hittable.h"
#include "hittable_list.h"
#include "sphere.h"

#include <iostream>

color ray_color(ray & r,const hittable& world){
hit_record rec;
if(world.hit(r,0,infinity,rec)){ //只有t在(0,+无穷)的时候才成立
return 0.5*(rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y()+1.0);
return (1.0 - a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

int main(){
auto aspect_radio = 16.0/9.0; //长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//球体列表
hittable_list world;

world.add(make_shared<sphere>(point3(0,0,-1),0.5));
world.add(make_shared<sphere>(point3(0,-100.5,-1),100));

//确保视口的宽高比和图像的宽高比一样
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0,0,0);

//设置视口向量与单位长度
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
auto pixel_delta_u = viewport_u/image_width;
auto pixel_delta_v = viewport_v/image_height;

//计算像素点位
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
auto pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);


//渲染器
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r,world);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

这个是我们渲染出来的新图片,我们设置了一个大的球体在下面作为一个地面。

image.png

区间类

我们在判断是否击中的hit()函数中我们经常设置一个区间(tmin,tmax),为了方便之后的使用,我们将这个区间类给抽象出来:

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

#ifndef RENDER_C___INTERVAL_H
#define RENDER_C___INTERVAL_H

#include "rtweekend.h"

class interval{
public:
double min,max;
//默认区间是空的
interval() : min(+infinity),max(-infinity) {}

interval(double min,double max): min(min),max(max) {}

double size() const{
return max - min;
}
//闭区间
bool contains(double x) const {
return min <= x && x <= max;
}
//开区间
bool surrounds(double x) const{
return min < x && x < max;
}

static const interval empty,universe;
};

const interval interval::empty = interval(+infinity,-infinity);
const interval interval::universe = interval(-infinity,+infinity);

#endif //RENDER_C___INTERVAL_H

我们现在可以用这个头文件去更新之前用到了区间的程序

1
2
3
4
5
6
//hittable.h
class hittable {
public:
...
virtual bool hit(const ray& r, interval ray_t, hit_record& rec) const = 0;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//hittable_list.h
class hittable_list : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_t.max;

for (const auto& object : objects) {
if (object->hit(r, interval(ray_t.min, closest_so_far), temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}

return hit_anything;
}
...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//sphere.h
class sphere : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
...

auto root = (h - sqrtd) / a;
if (!ray_t.surrounds(root)) {
root = (h + sqrtd) / a;
if (!ray_t.surrounds(root))
return false;
}
...
}
...
};
1
2
3
4
5
6
7
8
9
10
color ray_color(const ray& r, const hittable& world) {
hit_record rec;
if (world.hit(r, interval(0, infinity), rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

以上就是需要更新的地方啦

这一篇的内容比较多,写了差不多两天,得好好消化一下啦。

学到一半区研究旋转立方体去了,确实好玩,受益匪浅啊,刚好搞明白了上一章相机的设置和视图的投影,现在继续学习。

添加一个球

我们现在向我们的光线追踪器中添加一个物体。我们从添加一个球体开始(因为它是最容易分析实现的)

射线-球面的交汇

这一部分将有大量的公式,但是我现在还没有搞明白Latex的渲染问题(这个太麻烦了,我已经试过几次了),可能会用比较丑陋方式来表达或者直接上截图

我们知道球体的表达式:

1
x^2 + y^2 + z^2 = r^2

当球心为C(C_x,C_y,C_z)时,我们有:

1
(C_x - x)^2 + (C_y - y)^2 + (C_z - z)^2 = r^2

但是这个是数值上的运算,我们需要想办法将它转换成向量vec3的形式,这里观察可得,实际上我们可以用一点P(x,y,z)来表示球体上的任意一点,也就是说从CP向量,可以用point(C)-point(P)来表示,现在我们可以用向量的概念去理解这个式子:

1
(point(C)-point(P))*(point(C)-point(P)) = (C_x - x)^2 + (C_y - y)^2 + (C_z - z)^2 = r^2

现在将射线的路径和球体的方程式联立起来:

1
2
3
4
5
6
P(t) = Q + td
(point(C)-point(P))*(point(C)-point(P)) = r^2

联立得:

t^2*vec(d)*vec(d) - 2*t*(point(C)-point(Q)) + (point(C)-point(Q))*(point(C)-point(Q)) - r^2 = 0

这是一个关于t的一元二次方程,我们可以根据医院二次方程的求解方程式来计算t,并得到射线与球体的相交情况(注意以下的乘法都是点乘):

1
2
3
4
5
6
求解方程式:
x = (-b +- sqrt(b^2 - 4*a*c))/(2*a)
根据联立方程式得到的数值:
a = vec(d)*vec(d)
b = -2*vec(d)*(point(C)-point(Q))
c = (point(C)-point(Q))*(point(C)-point(Q)) - r^2

带入以上的数据就可以解出t,同时可以判断光线与球体的相交情况

image.png

创建一个带有小球的光追图像

我们现在将刚刚计算得到的数学公式硬编码进入我们的程序,并设置球体的中心在(0,0,-1),半径为0.5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin(); //计算(point(C)-point(Q))
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
return (discriminant >= 0);
}

color ray_color(ray & r){
if(hit_sphere(point3(0,0,-1),0.5,r))
return {1,0,0};

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们将这一部分添加进入先前的代码可以得到我们渲染出来的图像,中间的红色是我们添加的球体。

image.png

不过此时程序仍然存在一个问题,我们并不能区分摄像机前后的物体,由于射线的对称性,当球体位于(0,0,1)的时候,存在t的解为负数,导致渲染出相同的图片。

同时,我们现在还不能对物体进行前后的判断,还有阴影,反射光线等功能还有多个物体的共存,我们在接下来的学习中,慢慢尝试解决这个问题,今天就到此为止啦。

看到一个视频,对其内容比较感兴趣,刚好今天没什么事,我们来深入解析一下这个项目

视频链接:终端字符旋转立方体

参考链接:Donut math: how donut.c works

这是我们最终想要实现的效果(一个旋转的立方体):

image.png

旋转

怎么让一个立方体转起来,我们是怎么实现的?实际上这里用到的是线性代数的几何原理。

这里直接给出实现方法,通过三个旋转矩阵对向量进行三次线性变换,从而实现对点的旋转效果:

  • 我们以正方体的几何中心作为原点,此时正方体表面的任意一点可被表示为向量(i, j, k)
  • 现在使用通过绕X轴旋转的旋转矩阵,来实现变换image.png
  • 想要对于Y轴和Z轴的旋转的话,乘以对应的旋转矩阵即可实现image.png

这样我们就通过矩阵乘法实现了向量旋转的效果,我们可以得到旋转后的点的位置

这里我们使用wiki百科中的内旋矩阵来实现我们的计算image.png

接下来我们可以写出以下代码来计算我们的在任意时刻的点位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double A,B,C;
float calculateX(int i,int j,int k){
return i*cos(A)cos(B) +
j*cos(A)*sin(B)*sin(C) - j*sin(A)*cos(C) +
k*cos(A)*sin(B)*cos(C) + k*sin(A)*sin(C);
}
float calculateY(int i,int j,int k){
return i*sin(A)*cos(B) +
j*sin(A)*sin(B)*sin(C) + j*cos(A)*cos(C) +
k*sin(A)*sin(B)*cos(C) - k*cos(A)*sin(C);
}
float calculteZ(int i,int j,int k){
return -i*sin(B) +
j*cos(B)*sin(C) +
k*cos(B)*cos(C);
}

现在我们对立方体的模拟已经完成了,接下来我们需要想办法将其投影到我们的视图上面。

投影

我们根据这张图来进行解释image.png

这个object就是我们模拟出来的物体,也就是我们的正方体。screen是我们的屏幕,我们现在根据投影将物体投射在我们的屏幕上呈现出我们看到的效果。

这里可以看到得到等式关系y/z = y0/z0也就是说y0 = y*(z0/z)其中z0的值是保持不变的,我们将其设置为K1。我们就可以用等式xp = K1*ooz*x*2;yp = K1*ooz*y来表示它的坐标(这里xp需要乘2是因为终端中的字符有一定的长宽比,如果不乘以2,则投影出来的效果会比较窄)。

所以我们就可以写出我们的投影函数了:

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
// 变量
int idx;
int xp,yp;
float ooz;
float cubeWidth = 16; //正方体大小
int width = 160,height = 44; //视口大小
int distanceFromCamera = 60; //视口距离物体几何中心的距离
int K1 = 40; //相机到视口的距离
float zbuffer[160*44];
char buffer[160*44];

void calculateForSurface(float cubeX,float cubeY,float cubeZ,char ch){
//原点(0,0,0)实际上是相机的位置(即视点)
x = calculateX(cubeX,cubeY,cubeZ);
y = calculateY(cubeX,cubeY,cubeZ);
z = calculateZ(cubeX,cubeY,cubeZ) + distanceFromCamera;

ooz = 1/z; //one over zero
xp = (int)(width/2 + K1*ooz*x*2);//这里乘2平衡字符的宽高比
yp = (int)(height/2 + K1*ooz*y);

idx = xp + yp*width; //计算在数列中的位置
if(idx >= 0 && idx < width*height){
// z缓冲
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}

以上就是我们的投影函数了,你可能会注意到这一部分:

1
2
3
4
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}

这就是大名鼎鼎的Z缓冲技术(Z-buffering),如果没有这个程序可能我们投影出来的效果就会是透视的,我们看物体的前面却能看到物体的背面,这是因为后面的字符覆盖到了前面的字符。所以在这里我们需要对每个点进行深度检测,即计算他们的ooz,如果z越大说明离得越远,反之则说明离得近。每次进行缓冲区的覆写之前我们都先对其做一次判断,如果当前Z的深度大于先前的点,则忽略。

这样的设计确保只有最近的表面可见,避免远处的物体错误的覆盖近处的物体。

展示

现在我们已经完成了我们的模拟函数和渲染和函数,接下来就需要进行一系列的初始化,让正方体动起来

我们直接将main函数给出,然后进行解释:

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
float zbuffer[160*44];
char buffer[160*44];
int backgroundASCIIcode = ' ';
float increamentSpeed = 0.5;
int main(){
printf("\x1b[2j"); //cls的转义序列,用于清屏
while(1){
memset(buffer,backgroundASCIIcode,width*height);//设置背景为' '
memset(zbuffer,0,width*height*4); //设置深度为无限深度
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}
printf("\x1b[H"); //将光标置于左上角的转义序列,确保打印的视图稳定完整
printf("\x1b[?25l");//隐藏光标的转义序列,保证观看的优美
fflush(stdout);
//用于打印缓冲图像,并且用三元运算符号判断换行时机
for(int k=0 ;k< width*height;k++){
putchar(k%width ?buffer[k] : 10);
}
//设置转动
A += 0.04;
B += 0.04;
C += 0.04;
usleep(8000);
}
}

你可能会对这一部分代码感到困惑:

1
2
3
4
5
6
7
8
9
10
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}

实际上这是对正方体的初始化,和对其点位的跟踪计算,我们将正方体的几何中心视作原点(0,0,0),那么根据这个原点建系。Z轴指向垂直向内,Y轴指向竖直向上,X轴指向水平向右。那么我们就可以确定正方形六个面的位置了:

固定坐标 变化的坐标 几何意义
前面 Z = -10 XY 靠近屏幕的面(Z最小)
后面 Z = +10 XY 远离屏幕的面(Z最大)
右面 X = +10 YZ 立方体右侧的面(X最大)
左面 X = -10 YZ 立方体左侧的面(X最小)
顶面 Y = +10 XZ 立方体顶部的面(Y最大)
底面 Y = -10 XZ 立方体底部的面(Y最小)

至于CubeX和CubeY的值则是用来确定位置,以确保方向一定。

这么解释理解起来比较麻烦,实际上你仍然可以理解成一个矩阵变换的过程:

  • 我们设当前面上一点的位置为(x,y,z)
  • 那么右面上的点的表示即为,绕Y轴旋转90度的旋转矩阵的线性变换
  • 其他五个面同理,我们可以得到以下的线性关系:
面上一点的表示
前面 (x,y,z)
后面 (-x,y,-z)
右面 (-z,y,x)
左面 (z,y,-x)
顶面 (x,z,-y)
底面 (-x,z,y)

然后依次进行计算即可,简而言之这是一个将正方体内的坐标系转换到3D世界中的坐标系的一个过程

至此我们的程序就完成了,效果见下图:

image.png

源代码

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
72
73
74
75
76
77
78
79
#include<stdio.h>
#include<math.h>
#include<unistd.h>

int idx;
int xp,yp;
float ooz;
float x,y,z;
double A,B,C;
float cubeWidth = 16;
int width = 160,height = 44;
int distanceFromCamera = 60;
int K1 = 40;
float zbuffer[160*44];
char buffer[160*44];
int backgroundASCIIcode = ' ';
float increamentSpeed = 0.5;

float calculateX(float i,float j,float k){
return i*cos(A)*cos(B) +
j*cos(A)*sin(B)*sin(C) - j*sin(A)*cos(C) +
k*cos(A)*sin(B)*cos(C) + k*sin(A)*sin(C);
}
float calculateY(float i,float j,float k){
return i*sin(A)*cos(B) +
j*sin(A)*sin(B)*sin(C) + j*cos(A)*cos(C) +
k*sin(A)*sin(B)*cos(C) - k*cos(A)*sin(C);
}
float calculateZ(float i,float j,float k){
return -i*sin(B) +
j*cos(B)*sin(C) +
k*cos(B)*cos(C);
}
void calculateForSurface(float cubeX,float cubeY,float cubeZ,char ch){
x = calculateX(cubeX,cubeY,cubeZ);
y = calculateY(cubeX,cubeY,cubeZ);
z = calculateZ(cubeX,cubeY,cubeZ) + distanceFromCamera;

ooz = 1/z;
xp = (int)(width/2 + K1*ooz*x*2);
yp = (int)(height/2 + K1*ooz*y);

idx = xp + yp*width;
if(idx >= 0 && idx < width*height){
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}


int main(){
printf("\x1b[2j");
while(1){
memset(buffer,backgroundASCIIcode,width*height);
memset(zbuffer,0,width*height*4);
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}
printf("\x1b[H");
printf("\x1b[?25l");
fflush(stdout);
for(int k=0 ;k< width*height;k++){
putchar(k%width ?buffer[k] : 10);
}
A += 0.04;
B += 0.04;
C += 0.04;
usleep(8000);
}
}

昨天休息了一天,今天继续图形学的学习

向场景发射光线

现在我们我们准备做一个光线追踪器。其核心在于,光线追踪程序通过每个像素发送光线。这意味着对于图像中的每个像素点,程序都会计算一天从观察者出发,穿过该像素的光线。并且计算这个光线的方向上所看到的像素的颜色。其步骤为以下几点:

  • 计算从“眼睛”发出的通过像素的光线
  • 确定光线与物体的交汇
  • 计算交点像素的颜色和性质

设置一个摄像机

除了设置渲染图像的像素维度,我们还需要一个虚拟视口,通过这个视口传递场景光线。这个视口是3D世界中的一个虚拟矩形,其中包含图像像素网格的位置。如果像素在水平方向和垂直方向上的距离相同(正方形像素),那么用于生成这些像素的视口也将具有具有相同的渲染比例。两个相近的像素之间的距离称之为像素间距,通常被单位化,用于计算。

首先我们随意设置一个为2的视口高度,并将视口宽度缩放,以获得我们需要的宽高比例,下面是渲染图像的设置:

1
2
3
4
5
6
7
8
9
10
auto aspect_radio = 16.0/9.0;	//长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//确保视口的宽高比和图像的宽高比一样
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);

这里之所以没有使用aspect_radio来计算视口宽度是因为,为了确保其比例更加的真实。因为aspect_ratio是一个理想的比例,但是并不真实。图像的宽高比可能会在四舍五入的过程中丢失精度,所以此时的aspect_ratio并不准确,要得到真正的宽高比,我们直接使用图像的宽高比来进行计算。

接下里我们需要定义摄像机的中心:一个3D空间中的点,所有的场景光线都将从这个点出发(这个点通常也被称之为视点)。从相机中心到视口中心的向量将与视口垂直。我们将视口到视点的距离看作一个单位。这个距离我们称其为焦距。

我们可以通过这张图理解视点和视口的关系,但是忽略图上的方向,我们只需要知道Z轴是从视点指向视口的方向即可

image.png

实际上我们将视口的左上角定为(0,0),然后扫描像素时从左上角开始,逐行从左到右扫描,然后逐行从上往下扫描。为了方便导航像素网格,我们设置从左往右的向量u⃗和从上往下的向量v⃗ 。然后我们根据像素间距,将像素视口均匀的分成了高x宽网格空间

image.png

下面我们写一个实现相机的代码,我们创建一个函数ray_color(const ray& r),它返回给定场景中的射线的颜色——我们先设置为总返回黑色。

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
#include "color.h"
#include "vec3.h"
#include "ray.h"

#include <iostream>

color ray_color(const ray& r){
return {0,0,0};
}

int main(){
auto aspect_radio = 16.0/9.0; //长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//确保视口的宽高比和图像的宽高比一样
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0,0,0);

//设置视口向量与单位长度
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
auto pixel_delta_u = viewport_u/image_width;
auto pixel_delta_v = viewport_v/image_height;

//计算像素点位
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
auto pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);


//渲染器
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

以上就是一个摄像机的实现了,接下来我们用它来实现背景的渲染。

渲染背景

我们接下来想要渲染一个随y变化的由蓝变白的背景,这就需要我们修改ray_color(ray)函数,从而实现一个简单的梯度函数,这个函数将根据y值来按线性规则混合白色和蓝色。

我们使用一个标准的图形技巧来实现线性混合:

blendValue = (1 − a) * startValue + a * endValue

当a接近0时,颜色趋近为起始颜色。当a接近1时,颜色接近目标颜色。

我们修改函数ray_color(ray& r)实现这个功能:

1
2
3
4
5
color ray_color(ray & r){
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们编译执行,得到了渲染后的图片:

image.png

今天就先到这里啦。

今天简单的学习一下相关类的定义和作用

Vec3类

图形程序中需要一些用于存储几何向量和颜色的类,这里的话我们设置一个最简单的,只需要三个坐标就够了。我们使用相同的类vec3来表示颜色、位置、方向、偏移。所以我们会为这个类声明另外两个别名point3color,但是要注意,不要将一个point3添加到一个color

我们定义一个类文件:

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#ifndef RENDER_C___VEC3_H
#define RENDER_C___VEC3_H

#include <cmath>
#include <iostream>
// 向量类
class vec3 {
public:
double e[3];

vec3(): e{0,0,0} {}
vec3(double e0,double e1,double e2): e{e0,e1,e2} {}

double x() const{ return e[0]; }
double y() const{ return e[1]; }
double z() const{ return e[2]; }

vec3 operator-() const {return {-e[0],-e[1],-e[2]};}
double operator[](int i) const { return e[i];}
double & operator[](int i) {return e[i];}

vec3& operator+=(const vec3& v){
e[0] += v.e[0];
e[1] += v.e[1];
e[2] += v.e[2];
return *this;
}

vec3& operator*=(double t){
e[0] *= t;
e[1] *= t;
e[2] *= t;
return *this;
}

vec3& operator/=(double t){
return *this *= 1/t;
}

double length_squared() const{
return e[0]*e[0]+e[1]*e[1]+e[2]*e[2];
}

double length() const {
return std::sqrt(length_squared());
}
};

// 设置别名
using point3 = vec3;

// 设置内联函数
inline std::ostream& operator<<(std::ostream& out,const vec3& v){
return out << v.e[0] << ' ' << v.e[1] << ' ' << v.e[2];
}

inline vec3 operator+(const vec3& u,const vec3& v){
return {u.e[0]+v.e[0],u.e[1]+v.e[1],u.e[2]+v.e[2]};
}

inline vec3 operator-(const vec3& u,const vec3& v){
return {u.e[0]-v.e[0],u.e[1]-v.e[1],u.e[2]-v.e[2]};
}

inline vec3 operator*(const vec3& u,const vec3& v){
return {u.e[0]*v.e[0],u.e[1]*v.e[1],u.e[2]*v.e[2]};
}

inline vec3 operator*(double t,const vec3& v){
return {t*v.e[0],t*v.e[1],t*v.e[2]};
}

inline vec3 operator*(const vec3& v,double t){
return t*v;
}

inline vec3 operator/(const vec3& v,double t){
return (1/t)*v;
}

inline double dot(const vec3& u,const vec3& v){
return u.e[0]*v.e[0] + u.e[1]*v.e[1] + u.e[2]*v.e[2];
}

inline vec3 cross(const vec3& u,const vec3& v){
return {u.e[1]*v.e[2]-u.e[2]*v.e[1],u.e[2]*v.e[0]-u.e[0]*v.e[2],u.e[0]*v.e[1]-u.e[1]*v.e[0]};
}

inline vec3 uint_vector(const vec3& v){
return v/v.length();
}

#endif //RENDER_C___VEC3_H

其中大量应用了函数的重载,不过对着《c++ primer plus》还是看的差不多了。

这里的小数我们使用了double数据类型,因为它更加的准确,不过还是以自己使用的机器为主,看你内存空间咯

vec3类在颜色中的应用

基于vec3类,定义一个color.h,并向里面定义一个写入像素的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

#ifndef RENDER_C___COLOR_H
#define RENDER_C___COLOR_H

#include "vec3.h"

using color = vec3;

void write_color(std::ostream& out,const color& pixel_color){
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();

int rbyte = int (255.999 * r);
int gbyte = int (255.999 * g);
int bbyte = int (255.999 * b);

out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}

#endif //RENDER_C___COLOR_H

现在我们可以用我们定义的类去实现我们昨天手搓出来的图片了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "color.h"
#include "vec3.h"

#include <iostream>

int main(){
int image_width = 256;
int image_height = 256;

std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: "<< (image_height-j) << '\r' << std::flush;
for(int i =0;i<image_width;i++){
auto pixel_color = color(double(i)/(image_width-1),double(j)/(image_height-1),0);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone\n";
}

虽然没有节省多少内容,但是在后续的过程中,这个简便性会慢慢体现出来。

基于vec3实现射线类

我们需要一个光线类,来实现对光线的计算。我们可以用函数P(t) = A + bt来实现光线路径的模拟。其中A指的是射线的起点,b指的是射线的方向,t是光线的延伸。我们将这个射线的概念封装为一个类,其中用于计算的函数P(t)用函数ray::at(t)表示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#ifndef RENDER_C___RAY_H
#define RENDER_C___RAY_H

#include "vec3.h"

class ray{
public:
ray(){}

ray(const point3& origin,const vec3& direction): orig(origin),dir(direction){}

const point3& origin() const {return orig;}
const vec3& direction() const {return dir;}

point3 at(double t) const{
return orig + t*dir;
}
private:
point3 orig;
vec3 dir;
};

#endif //RENDER_C___RAY_H

这里将射线的起点和方向进行了封装,只能通过接口访问以确保程序的完整与安全

现在我们接下来要用到几个类就设置完成啦

最近对图形学比较感兴趣,虽然感觉找不到工作,但是帅,我花点时间学一下,刚好练习一下C++

输出一个图像

ppm

当我们启动一个渲染器时,我们需要一种方式来查看我们的图像。最简单的方式就是将它写入文件中,这里我们使用PPM格式(因为它比较简单)

image.png

这是PPM文件格式在维基中的表现:

  • 第一行是文件描述子 什么P1 P2 P3代表了不同的图像类型,这里我们使用P3——像素图
  • 第二行是像素的宽高 先宽后高
  • 第三行表示每个像素分量的最大值 255 即8位色彩深度
  • 下面是像素的信息,每一行的最后用换行符结尾

图形学的Hello World

我们可以用简单的代码实现一个这样的图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
int main(){
int image_width = 256;
int image_height = 256;

std::cout << "P3\n" << image_width << " " << image_width << "\n255\n";

for (int j =0 ;j<image_height;j++){
for(int i=0;i<image_width;i++){
auto r = double (i)/(image_width-1);
auto g = double (j)/(image_height -1);
auto b = 0.0;

int ir = int (255.999 *r);
int ig = int (255.999 *g);
int ib = int (255.999 *b);
// 255.999确保在浮点数计算中的极小误差在转换为整数的过程中不会被放大,可以理解成这是一种技巧,用来避免色彩的丢失
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
}

我们将输入定向到一个文件中,然后更改后缀名打开

image.png

可以看到我们生成的图片啦

添加一个程序进度指示器

有时候长时间渲染需要进行跟踪,以判断是否陷入死循环中,或者查看渲染进度。

我们可以通过添加日志输出流来实现这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
int main(){
int image_width = 256;
int image_height = 256;

std::cout << "P3\n" << image_width << " " << image_width << "\n255\n";

for (int j =0 ;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto r = double (i)/(image_width-1);
auto g = double (j)/(image_height -1);
auto b = 0.0;

int ir = int (255.999 *r);
int ig = int (255.999 *g);
int ib = int (255.999 *b);

std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
std::clog << "\rDone \n";
}

这样就可以啦

你真的喜欢你现在正在做的事情吗?这真是你想学的吗?你以后还会继续喜欢它吗?

我和一位学艺术的同学聊到这个话题,我们都很喜欢自己正在做的事情。他说 “我很怕自己以后突然有一天不喜欢画画了” ,我听得很难过,因为我已经开始不喜欢我正在做得事情了。好不容易找到自己想做的事,难道仅仅也是因为新鲜感吗?他接着说 “我觉得很多人并不是喜欢这件事,而是喜欢它为你带来的价值。真的有人喜欢数学吗?有但是少数,可是为什么那么多人说自己喜欢数学呢?因为在数学上他比别人更强,数学能够为他带来优越感,和各种荣誉。”我又何尝不是这样呢?我学的大多数知识已经是为比赛开始服务了,我喜欢的是这些知识,还是比赛为我带来的荣誉呢?

我突然很害怕,这是我早就意识到的一个问题。大概是新生赛时期拿到过一次第一吧,大多数人对我的印象是一个很优秀很有潜力的CTFer,我也这么觉得,我觉得就该这么下去,以后一定会越来越强。大概是从这个时候开始我就变了吧,我搞丢了我的初心。我可以拿CTF为由去拒绝学习其他我不想学的东西,即使我不上课,不写作业,文化课乱成一团,我也觉得情有可原。别人也很“理解”我,“没关系,你看你技术学的那么好”。真的是如此吗?

我一点也不喜欢这样,我可以肯定。这些我都不在乎,我想得到的不是这些,我想知道的是计算机背后的细节,一个个门电路是怎么搭建出的计算机,程序是怎么链接加载的,电脑是怎么运行的。我想写自己的东西,不是给别人看的东西,不是为了比赛而匆匆赶制的作品。很多人说不好就业,这样焦虑那样焦虑,并不重要,我是在做我喜欢的事情,很多人一辈子都不知道自己想做什么,只顾着养活自己。但是我有我自己想做的事情,我不甘心被这些东西阻挠,这些那些放做一旁。我有很多想做的事情,想学的知识。

当然,无论怎么做总有人质疑你,我以前总是被这个困扰,我想做出最好的选择,让所有人都认可。但从来都不是这样的,你有理想,别人嫉妒你,说理想不能当饭吃,你还是得养活自己。你现实,别人忌惮你,说别只知道学习,别太功利,不然会很累。大多数人是见不得别人好的,见不得别人有自己的事情要做。巴不得所有人和自己一样过的迷茫错误。上了大学才发现,这种人到处都是。

我的同学是一个很纯粹的艺术生,喜欢素描和速写,他追求的是真正的艺术,能让自己满意,能让他人开心的艺术,我很羡慕他。其实我和他也一样,我追求纯粹的知识,我想知道的,能让我说出原来如此的知识,仅此而已。希望之后的学习之旅,能够不忘初心,慢慢加油。

项目地址 https://www.jmeiners.com/lc3-vm/#running-the-vm

什么是虚拟机

虚拟机是一种像计算机一样的程序。它模拟CPU和一些其他的硬件组件的功能,使其能够实现算术运算,内存读写,还有与I/O设备的交互。最重要的是,你可以理解你为其定义的机器语言,然后用它来编程。

LC-3架构

这次编写的虚拟机将模拟LC-3架构。和X86架构相比,它的指令集更加简单

内存

LC-3中有65536个内存位置(这是16位无符号整数能表示的最大地址),每个位置存储一个16位的值。这意味它只能存储128KB的值,我们将其存储在一个数组中

1
2
3
// Memory Storage
#define MEMORY_MAX (1<<16)
uint16_t memory[MEMORY_MAX]; //65536个16位的内存空间

寄存器

LC-3共有10个寄存器,每个寄存器都为16位的。其功能分布如下:

  • 通用寄存器(R0-R7)—— 8:执行程序计算
  • 程序计数器(PC)—— 1:表示内存中将要执行的下一条指令
  • 条件寄存器(COND)—— 1:计算的信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Registers
enum{
R_R0 = 0,
R_R1,
R_R2,
R_R3,
R_R4,
R_R5,
R_R6,
R_R7,
R_PC,
R_COND,
R_COUNT
};

我们也将其保存在一个数组中

1
2
// Register Storage
uint16_t reg[R_COUNT];

指令集

LC-3中只有16个操作指令,我们将用他们实现计算机中的各种内容。每个指令的长度是16位,我们用左边的4位用来存储操作码(操作指令对应的机器码),其它位置用来存放参数

接下来定义操作码,并分配对应的枚举值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Opcodes
enum{
OP_BR = 0, //条件分支
OP_ADD, //加
OP_LD, //加载数据
OP_ST, //存储数据
OP_JSR, //子程序调用
OP_AND, //按位与
OP_LDR, //基址加载
OP_STR, //基址存储
OP_RTI, //不使用
OP_NOT, //按位或
OP_LDI, //间接加载
OP_STI, //间接存储
OP_JMP, //跳转
OP_RES, //不使用
OP_LEA, //加载有效地址
OP_TRAP //系统调用
};

条件标志

R_COND寄存器存储条件标志,这些标志提供了最近的计算的信息,使得程序能检查逻辑条件。这里LC-3至使用3个条件标志,这些条件标志指示前一次的计算符号

1
2
3
4
5
6
// Condition Flags
enum{
FL_POS = 1 << 0, //P
FL_ZRO = 1 << 1, //Z
FL_NEG = 1 << 2, //N
}

现在我们已经完成虚拟机中硬件组件的设置。可以进一步尝试虚拟机的实现了,此时文件结构应该是这样

1
2
3
4
5
@{Includes}

@{Registers}
@{Condition Flags}
@{Opcodes}

汇编示例

现在我们尝试写一个LC-3汇编的程序,以了解虚拟机上发生的事情

1
2
3
4
5
6
.ORIG x3000			;程序将被加载到内存的这个位置
LEA R0,HELLO_STR ;将字符串加载到R0中
PUTs ;将R0指向的字符串输出
HALT ;中断
HELLO_STR .STRINGZ "Hello,World!" ;程序中字符串存储于此
.END ;结束标记
  • 这个指令是线性执行的,和C语言以及其他语言中的{}作用域不同
  • .ORIG.STRINGZ是汇编器指令,用于生成一段代码或数据(像宏一样)

尝试循环和条件语句的使用通常使用类似goto的指令实现。比如计数到10:

1
2
3
4
5
AND R0, R0, 0		;清空R0
LOOP ;设置跳转标签
ADD R0, R0, 1 ;R0 = R0 + 1
ADD R1, R0, -10 ;R1 = R0 - 10
BRn LOOP ;若为负数则跳转

执行程序

上面的例子让你大致理解了这个虚拟机的功能。使用LC-3的汇编语言,你甚至能在你的虚拟机上浏览网页,或者Linux系统

过程

以下是我们编写程序的步骤:

  • 从PC寄存器存储的内存地址中加载一条指令
  • 递增PC寄存器
  • 查看指令的操作码以确定它执行那种类型的指令
  • 使用指令中的参数执行指令
  • 返回第一步

当然我们也需要whileif之类的指令,帮我们实现指令的跳转,不然编程量会很大。所以在这里我们会通过类似于goto的指令来跳转PC从而改变执行流程,我们开始编写:

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
// Main Loop
int main(int argc,const char * argv[]){
@{Load Arguments}
@{Setup}

//无论何时都应该设定一个条件标志,这里设置Z标志
reg[R_COND] = FL_ZRO;

//为PC设置一个起始的内存加载地址,0x3000为默认
enum{PC_START = 0x3000};
reg[R_PC] = PCSTART;

int running = 1;
while (running){
//读取
uint16_t instr = mem_read(reg[R_PC]++);
uint16_t op = instr >> 12;

switch(op){
case OP_ADD:
@{ADD}
break;
case OP_AND:
@{AND}
break;
case OP_NOT:
@{NOT}
break;
case OP_BR:
@{BR}
break;
case OP_JMP:
@{JMP}
break;
case OP_JSR:
@{JSR}
break;
case OP_LD:
@{LD}
break;
case OP_LDI:
@{LDI}
break;
case OP_LDR:
@{LDR}
break;
case OP_LEA:
@{LEA}
break;
case OP_ST:
@{ST}
break;
case OP_STI:
@{STI}
break;
case OP_STR:
@{STR}
break;
case OP_TRAP:
@{TRAP}
break;
case OP_RES:
case OP_RTI:
default:
@{BAD OPCADE}
break;
}
}
@{Shutdown}
}

我们在主循环中,处理命令行参数,使我们的程序可用。我们期望输入的参数是一个虚拟机镜像,如果不是的话,则给出参数输入的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
// Load Arguments
if(argc < 2){
//展示用法
printf("lc3 [image-file1] ...\n");
exit(2);
}

for(int j = 1;j < argc; ++j){
if(!read_image(argv[j])){
printf("failed to load image: %s\n",argv[j]);
exit(1);
}
}

指令实现

我们现在需要将每个操作码情况都填充为正确的实现,我们可以在LC-3中找到指令的正确实现。下面会演示两个指令的实现

ADD

ADD指令取两个数字相加,并将结果存储在寄存器中。其规范如下:

image.png

编码显示了两行,因为这条指令有两种模式。可以看到前四位(这里是小端序)都是一样的,0001是OP_ADD的操作码。接下来3位都是DR,这代表目标寄存器。目标寄存器是用来存储加和的位置。再接下来3位是SR1,这是包含第一个要加的数字的寄存器。

区别在于最后五位,注意第5位,这个位表示ADD是立即数模式还是寄存器模式,在寄存器模式中,则是将两个寄存器相加,第二个寄存器被标记为SR2。其使用方法如下:

1
ADD R2 R0 R1	;R2 = R0 + R1

立即数模式则是将第二个值嵌入指令本身,如图中所示标识为imm5,消除了编写将值从内存中读取的需要。不过限于指令的长度,数字最多25 = 32(无符号),这使得即时模式主要用于递增和递减,其使用如下:

1
ADD R0 R0 1		;R0 = R0 + 1

其具体规范如下:

If bit [5] is 0, the second source operand is obtained from SR2. If bit [5] is 1, the second source operand is obtained by sign-extending the imm5 field to 16 bits. In both cases, the second source operand is added to the contents of SR1 and the result stored in DR. (Pg. 526)

这里的话和我们刚刚分析的差不多,但是在于它提到的"sign-extending"。这是啥用的呢?要知道,我们的加法是16位的,但我们的立即数是5位的,所以我们需要将它拓展到16位以匹配另一个数字。对于正数我们只需要补充0即可,但是对于负数我们需要用1填充,从而保持原始值

1
2
3
4
5
6
7
// Sign Extend
uint16_t sign_extend(uint16_t x, int bit_count){
if((x>>(bit_count-1))&1){
x |= (0xFFFF << bit_count);
}
return x;
}

规范中还有一句填充:

The condition codes are set, based on whether the result is negative, zero, or positive. (Pg. 526)

条件码根据结果是否为负,0,正来设置。我们之前定义了一个条件标志枚举,现在我们需要用到他们。每当有一个值被写入寄存器中,我们需要更新条件标志寄存器来标识它的符号。我们写一个函数来进行这个过程:

1
2
3
4
5
6
7
8
9
// Update Flags
void update_flags(uint16_t r){
if(reg[r] == 0)
reg[R_COND] = FL_ZRO;
else if(reg[r] >> 15) //当1是最高位时说明这是一个负数
reg[R_COND] = FL_NEG;
else
reg[R_COND] = FL_POS
}

至此我们可以编写ADD了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ADD
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//第一操作数
uint16_t r1 = (instr >> 6) & 0x7;
//是否是立即数
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] + imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] + reg[r2];
}
update_flags[r0]
}

至此,我们的ADD指令编写完成了,接下来我们将使用符号拓展,不同模式和更新标志的组合去实现其他的指令

LDI

LDI的话是间接加载的意思。这个指令将内存地址中的值加载到寄存器中,下面是它的指令样式:

image.png

可以看到前面的结构和ADD差不多,一个操作码,加上一个目标寄存器,剩余的位被标记为PCoffset9。这是一个嵌入在指令中的立即值(类似imm5)。由于这个指令从内存中读取,所以推测这个数字是一个地址,告诉我们从哪里加载:

An address is computed by sign-extending bits [8:0] to 16 bits and adding this value to the incremented PC. What is stored in memory at this address is the address of the data to be loaded into DR. (Pg. 532)

大概的意思是,我们需要将这个9位的值进行符号拓展,将其加到当前的PC上,结果之和则是一个内存的地址,这个地址包含另一个值,也就是我们将要加载的值的地址

这种方式读取内存可能很绕,但实际上这是必不可少的。LD指令仅限于9位地址偏移量,但是内存需要16位寻址,LDI对于加载存储在离当前PC位置较远的数据时更有用。与之前一样,将值放到DR后需要更新标志,其实现如下:

1
2
3
4
5
6
7
8
9
10
// LDI
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//初始化PCoffset
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
//将偏移值与PC相加,并读取对应的内存中的值到寄存器中
reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
update_flags(r0);
}

现在我们实现了两个主要的指令,接下来的用差不多的方式就可以写出来了

其他

RTI & RES

并不使用

1
2
// BAD OPCODE
abort();

AND

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// AND
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] & imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] & reg[r2];
}
update_flags(r0)
}

NOT

1
2
3
4
5
6
7
8
// NOT
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;

reg[r0] = ~reg[r1];
update_flags(r0);
}

BR

1
2
3
4
5
6
7
8
// BR
{
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
uint16_t cond_flag = (instr >> 9) & 0x7;
if(cond_flag & reg[R_COND]){ //cond_flag设置了跳转的条件
reg[R_PC] += pc_offset;
}
}

JMP

这里我们将RET视作JMP的一种特殊情况

1
2
3
4
5
// JMP
{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1];
}

JSR

1
2
3
4
5
6
7
8
9
10
11
12
// JSR
{
uint16_t long_flag = (instr >> 11) & 1;
reg[R_R7] = reg[R_PC];
if(long_flag){
uint16_t long_pc_offset = sign_extend(intstr & 0x7FF,11);
reg[R_PC] += long_pc_offset; //JSR,加上偏移值
}else{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1]; //JSRR,跳转到寄存器指定地址
}
}

LD

1
2
3
4
5
6
7
// LD
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = mem_read(reg[R_PC] + pc_offset); //在PC上偏移
update_flags(r0);
}

LDR

1
2
3
4
5
6
7
8
// LDR
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(instr & 0x3F,6);
reg[r0] = mem_read(reg[r1] + offset);
update_flags(r0);
}

LEA

1
2
3
4
5
6
7
// LEA
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = reg[R_PC] + pc_offset;
update_flags(r0);
}

ST

1
2
3
4
5
6
// ST
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(reg[R_PC] + pc_offset,reg[r0]);
}

STI

1
2
3
4
5
6
// STI
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(mem_read(reg[R_PC] + pc_offset),reg[r0]);
}

STR

1
2
3
4
5
6
7
// STR
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(intstr & 0x3F,6);
mem_write(reg[r1] + offset,reg[r0])
}

中断例程 Trap routines

LC-3中提供了一些预定的例程,用来执行常见的任务,以及与I/O间的设备交互。例如,从键盘获取输入和向控制台显示字符串的中断例程。可以将其作为LC-3的操作系统API理解。每个例程分配了一个中断码,我们定义一个枚举来实现他们,下面是TRAP指令的格式

image.png

其中的0~7位用于存放中断码,我们据此来实现他们:

1
2
3
4
5
6
7
8
9
// TRAP Codes
enum{
TRAP_GETC = 0x20, //从键盘中读取输入,但是不打印至终端
TRAP_OUT = 0x21, //输出字符
TRAP_PUTS = 0x22, //输出字符串(双字节单字符)
TRAP_IN = 0x23, //从键盘中读取输入,且打印至终端
TRAP_PUTSP = 0x24, //输出字符串(单字节单字符)
TRAP_HALT = 0x25 //中断程序
};

中断例程并不是指令,但是它为LC-3的运行提供了各种快捷的方式。在LC-3中,这些例程实际上是使用汇编编写的,当中断例程被调用了,程序被跳转到这里,执行后返回程序。(为什么程序从0x3000开始加载而不是0x0也是因为这个原因,需要一部分空间用来存储中断例程的代码)

在LC-3中我们直接使用C语言进行中断例程的编写,而不是汇编。我们不用从汇编开始写自己的I/O设备,我们可以发挥虚拟机的优势,使用更好的更搞层次的设备去仿真这个过程

我们在TRAP中使用一个switch来进一步的编写这个指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// TRAP
reg[R_R7] = reg[R_PC];
switch(instr & 0xFF){
case TRAP_GETC:
@{TRAP_GETC}
break;
case TRAP_OUT:
@{TRAP_OUT}
break;
case TRAP_PUTS:
@{TRAP_PUTS}
break;
case TRAP_IN:
@{TRAP_IN}
break;
case TRAP_PUTSP:
@{TRAP_PUTSP}
break;
case TRAP_HALT:
@{TRAP_HALT}
break;
}

接下来进一步的实现例程中的内容

PUTS

功能的话类似于C语言中的printf,但是我们字符串和C中的有所不同,LC-3中的字符并不是被单独存储为一个字节,而是被存储为一个内存位置。LC-3中的内存位置并不是16位,所以字符串中的每个字符都是16位的。为了用C的方法将其打印出来,我们需要将它转换为一个字符并单独输出

1
2
3
4
5
6
7
8
9
10
// TRAP PUTS
{
// 每个字符占一个字(16bits)
uint16_t *c = memory + reg[R_R0];
while(*c){
putc((char)*c,stdout); //强制类型转换
++c;
}
fflush(stdout);
}

以这个为例,我们可以进一步的编写其他的例程

GETC

1
2
3
4
5
6
// TRAP GETC
{
// 读取一个ASCII字符
reg[R_R0] = (uint16_t)getchar();
update_flags(R_R0);
}

OUT

1
2
3
4
5
// TRAP OUT
{
putc((char)reg[R_R0],stdout);
fflush(stdout);
}

IN

1
2
3
4
5
6
7
8
9
// TRAP IN
{
printf("Enter a character: ");
char c = getchar();
putc(c,stdout);
fflush(stdout);
reg[R_R0] = (uint16_t)c;
update_flags(R_R0);
}

PUTSP

1
2
3
4
5
6
7
8
9
10
11
12
13
// TRAP PUTSP
{
//刚刚的字符串打印是适用于双字节单字符的情况,现在是单字节单字符的情况
uint16_t *c = memory + reg[R_R0];
while(*c){
char char1 = (*c) & 0xFF;
putc(char1,stdout);
char char2 = (*c) >> 8;
if(char2) putc(char2,stdout);
++c;
}
fflush(stdout);
}

HALT

1
2
3
4
5
6
//HALT	
{
puts("HALT");
fflush(stdout);
running = 0;
}

程序加载

我们也许可以注意到指令是从内存中加载和进行执行的,但是指令是怎么进入内存的呢?当汇编被转换为机器指令时,它变成了一个包含了一系列的数据与指令的文件,可以将它复制到内存中进行加载。

16位的程序文件头指出了程序在内存中起始的地址。这个地址被称之为起始地址(origin)。它首先被读取,然后从起始地址开始将其余数据从文件读入内存。以下是LC-3中加载内存的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Reas Image File
void read_image_file(FILE * file){
//起始地址让我们知道内存从什么地方开始加载
uint16_t origin;
fread(&origin,sizeof(orgin),1,file);
origin = swap16(origin);

//由于我们有最大的内存范围,所以我们可以通过起始位置,确定加载范围
uint16_t max_read = MEMORY_MAX - origin;
uint16_t *p = memory + origin;
size_t read = fread(p,sizeof(uint16_t),max_read,file);

//转换为小端序
while(read-- > 0){
*p = swap16(*p);
++p;
}
}

注意这里swap16被调用于每一个数据块,这是因为LC-3是大端序结构,但是大多数现代计算机采用的是小端序结构,所以我们需要将每个uint16数据都转换一遍

1
2
3
uint16_t swap16(uint16_t x){
return (x << 8)|(x >> 8);
}

我们进一步的封装read_image_file()程序:

1
2
3
4
5
6
7
int read_image(const char * image_path){
FILE *file = fopen(image_path,"rb");
if(!file){return 0;};
read_image_file(file);
fclose(file);
return 1;
}

内存映射寄存器

有一些特殊的寄存器是不能通过普通的寄存器组去访问的。相反,在内存中为其保留了特殊的地址。要读写这些寄存器只需要读写他们的内存地址。这个过程便被称为内存映射寄存器。这通常用于一些特殊的硬件设备操作

LC-3有两个内存映射寄存器需要实现。一个是键盘状态寄存器(KBSR),一个是键盘数据寄存器(KBDR)。KBSR用来指示按键是否被按下,KBDR用来指示哪个按键被按下了

虽然可以使用GETC来完成对键盘输入的读取,但是在读取到输入之前,它会一直阻塞程序的执行。而KBSR和KBDR则可以实现对键盘设备的轮询,以确保程序在等待输入的时候持续执行

1
2
3
4
5
//Memory Mapped Registers
enum{
MR_KBSR = 0xFE00,
MR_KBDR = 0xFE02
};

内存映射寄存器使得内存访问变得有点复杂,使得你不能直接对内存数组进行访问。所以我们需要编写函数以实现对内存的读写。当从KBSR读取内存时,读取函数将检查键盘并更新两个内存位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Memory Access
void mem_write(uint16_t address,uint16_t val){
memory[address] = val;
}

uint16_t mem_read(uint16_t address){
if(address == MR_KBSR){
if(check_key()){
memory[MR_KBSR] = (1 << 15);
memory[MR_KBDR] = getchar();
}else{
memory[MR_KBSR] = 0;
}
}
return memory[address];
}

到此为止,VM的最后一个组件也模拟实现了,我们可以尝试运行它了。

平台特定信息

不同系统中的键盘读入和终端打印的实现可能有所不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Input Buffering
struct termios original_tio;

void disable_input_buffering(){
tcgetattr(STDIN_FILENO,&original_tio);
struct termios new_tio = original_tio;
new_tio.c_lflag &= ~ICANON & ~ECHO;
tcsetattr(STDIN_FILENO,TCSANOW,&new_tio);
}

void restore_input_buffering(){
tcsetattr(STDIN_FILENO,TCSANOW,&original_tio);
}

uint16_t check_key(){
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO,&readfds);

struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 0;
return select(1,&readfds,NULL,NULL,&timeout) != 0;
}

组合程序

我们利用刚刚写好的程序来对缓冲进行设置,以实现正确处理终端输入。

在main函数开头设置代码:

1
2
3
//Setup
signal(SIGINT,handle_interrupt);
disable_input_buffering();

当程序结束后,我们将中断设置为正常状态:

1
2
//Shutdown
restore_input_buffering();

设置也应该在接收到结束信号时恢复:

1
2
3
4
5
6
//Handle Interrupt
void handle_interrupt(int signal){
restore_input_buffering();
printf("\n");
exit(-2);
}

OK 我们的程序将要实现,我们将其组合便可得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@{Memory Mapped Registers}
@{TRAP Codes}

@{Memory Storage}
@{Register Storage}

@{Input Buffering}
@{Handle Interrupt}
@{Sign Extend}
@{Swap}
@{Update Flags}
@{Read Image File}
@{Read Image}
@{Memory Access}

@{Main Loop}

完整代码

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
#include <stdio.h>
#include <stdint.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/termios.h>
#include <sys/mman.h>

#define MEMORY_MAX (1<<16)


//Memory Mapped Registers
enum{
MR_KBSR = 0xFE00,
MR_KBDR = 0xFE02
};

enum{
TRAP_GETC = 0x20, //从键盘中读取输入,但是不打印至终端
TRAP_OUT = 0x21, //输出字符
TRAP_PUTS = 0x22, //输出字符串(双字节单字符)
TRAP_IN = 0x23, //从键盘中读取输入,且打印至终端
TRAP_PUTSP = 0x24, //输出字符串(单字节单字符)
TRAP_HALT = 0x25 //中断程序
};
enum{
OP_BR = 0, //条件分支
OP_ADD, //加
OP_LD, //加载数据
OP_ST, //存储数据
OP_JSR, //子程序调用
OP_AND, //按位与
OP_LDR, //基址加载
OP_STR, //基址存储
OP_RTI, //不使用
OP_NOT, //按位或
OP_LDI, //间接加载
OP_STI, //间接存储
OP_JMP, //跳转
OP_RES, //不使用
OP_LEA, //加载有效地址
OP_TRAP //系统调用
};
uint16_t memory[MEMORY_MAX]; //65536个16位的内存空间
enum{
R_R0 = 0,
R_R1,
R_R2,
R_R3,
R_R4,
R_R5,
R_R6,
R_R7,
R_PC,
R_COND,
R_COUNT
};
// Register Storage
uint16_t reg[R_COUNT];
enum{
FL_POS = 1 << 0, //P
FL_ZRO = 1 << 1, //Z
FL_NEG = 1 << 2, //N
};

struct termios original_tio;

void disable_input_buffering(){
tcgetattr(STDIN_FILENO,&original_tio);
struct termios new_tio = original_tio;
new_tio.c_lflag &= ~ICANON & ~ECHO;
tcsetattr(STDIN_FILENO,TCSANOW,&new_tio);
}

void restore_input_buffering(){
tcsetattr(STDIN_FILENO,TCSANOW,&original_tio);
}

uint16_t check_key(){
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO,&readfds);

struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 0;
return select(1,&readfds,NULL,NULL,&timeout) != 0;
}

//Handle Interrupt
void handle_interrupt(int signal){
restore_input_buffering();
printf("\n");
exit(-2);
}

uint16_t sign_extend(uint16_t x, int bit_count){
if((x>>(bit_count-1))&1){
x |= (0xFFFF << bit_count);
}
return x;
}

uint16_t swap16(uint16_t x){
return (x << 8)|(x >> 8);
}

void update_flags(uint16_t r){
if(reg[r] == 0)
reg[R_COND] = FL_ZRO;
else if(reg[r] >> 15) //当1是最高位时说明这是一个负数
reg[R_COND] = FL_NEG;
else
reg[R_COND] = FL_POS;
}

void read_image_file(FILE * file){
//起始地址让我们知道内存从什么地方开始加载
uint16_t origin;
fread(&origin,sizeof(origin),1,file);
origin = swap16(origin);

//由于我们有最大的内存范围,所以我们可以通过起始位置,确定加载范围
uint16_t max_read = MEMORY_MAX - origin;
uint16_t *p = memory + origin;
size_t read = fread(p,sizeof(uint16_t),max_read,file);

//转换为小端序
while(read-- > 0){
*p = swap16(*p);
++p;
}
}

int read_image(const char * image_path){
FILE *file = fopen(image_path,"rb");
if(!file){return 0;};
read_image_file(file);
fclose(file);
return 1;
}

void mem_write(uint16_t address,uint16_t val){
memory[address] = val;
}

uint16_t mem_read(uint16_t address){
if(address == MR_KBSR){
if(check_key()){
memory[MR_KBSR] = (1 << 15);
memory[MR_KBDR] = getchar();
}else{
memory[MR_KBSR] = 0;
}
}
return memory[address];
}

int main(int argc,const char * argv[]){
if(argc < 2){
//展示用法
printf("lc3 [image-file1] ...\n");
exit(2);
}

for(int j = 1;j < argc; ++j){
if(!read_image(argv[j])){
printf("failed to load image: %s\n",argv[j]);
exit(1);
}
}
signal(SIGINT,handle_interrupt);
disable_input_buffering();

//无论何时都应该设定一个条件标志,这里设置Z标志
reg[R_COND] = FL_ZRO;

//为PC设置一个起始的内存加载地址,0x3000为默认
enum{PC_START = 0x3000};
reg[R_PC] = PC_START;

int running = 1;
while (running){
//读取
uint16_t instr = mem_read(reg[R_PC]++);
uint16_t op = instr >> 12;

switch(op){
case OP_ADD:
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//第一操作数
uint16_t r1 = (instr >> 6) & 0x7;
//是否是立即数
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] + imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] + reg[r2];
}
update_flags(r0);
}
break;
case OP_AND:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] & imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] & reg[r2];
}
update_flags(r0);
}
break;
case OP_NOT:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;

reg[r0] = ~reg[r1];
update_flags(r0);
}
break;
case OP_BR:
{
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
uint16_t cond_flag = (instr >> 9) & 0x7;
if(cond_flag & reg[R_COND]){ //cond_flag设置了跳转的条件
reg[R_PC] += pc_offset;
}
}
break;
case OP_JMP:
{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1];
}
break;
case OP_JSR:
{
uint16_t long_flag = (instr >> 11) & 1;
reg[R_R7] = reg[R_PC];
if(long_flag){
uint16_t long_pc_offset = sign_extend(instr & 0x7FF,11);
reg[R_PC] += long_pc_offset; //JSR,加上偏移值
}else{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1]; //JSRR,跳转到寄存器指定地址
}
}
break;
case OP_LD:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = mem_read(reg[R_PC] + pc_offset); //在PC上偏移
update_flags(r0);
}
break;
case OP_LDI:
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//初始化PCoffset
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
//将偏移值与PC相加,并读取对应的内存中的值到寄存器中
reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
update_flags(r0);
}
break;
case OP_LDR:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(instr & 0x3F,6);
reg[r0] = mem_read(reg[r1] + offset);
update_flags(r0);
}
break;
case OP_LEA:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = reg[R_PC] + pc_offset;
update_flags(r0);
}
break;
case OP_ST:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(reg[R_PC] + pc_offset,reg[r0]);
}
break;
case OP_STI:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(mem_read(reg[R_PC] + pc_offset),reg[r0]);
}
break;
case OP_STR:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(instr & 0x3F,6);
mem_write(reg[r1] + offset,reg[r0]);
}
break;
case OP_TRAP:
{
reg[R_R7] = reg[R_PC];
switch(instr & 0xFF){
case TRAP_GETC:
{
// 读取一个ASCII字符
reg[R_R0] = (uint16_t)getchar();
update_flags(R_R0);
}
break;
case TRAP_OUT:
{
putc((char)reg[R_R0],stdout);
fflush(stdout);
}
break;
case TRAP_PUTS:
{
// 每个字符占一个字(16bits)
uint16_t *c = memory + reg[R_R0];
while(*c){
putc((char)*c,stdout); //强制类型转换
++c;
}
fflush(stdout);
}
break;
case TRAP_IN:
{
printf("Enter a character: ");
char c = getchar();
putc(c,stdout);
fflush(stdout);
reg[R_R0] = (uint16_t)c;
update_flags(R_R0);
}
break;
case TRAP_PUTSP:
{
//刚刚的字符串打印是适用于双字节单字符的情况,现在是单字节单字符的情况
uint16_t *c = memory + reg[R_R0];
while(*c){
char char1 = (*c) & 0xFF;
putc(char1,stdout);
char char2 = (*c) >> 8;
if(char2) putc(char2,stdout);
++c;
}
fflush(stdout);
}
break;
case TRAP_HALT:
{
//刚刚的字符串打印是适用于双字节单字符的情况,现在是单字节单字符的情况
uint16_t *c = memory + reg[R_R0];
while(*c){
char char1 = (*c) & 0xFF;
putc(char1,stdout);
char char2 = (*c) >> 8;
if(char2) putc(char2,stdout);
++c;
}
fflush(stdout);
}
break;
}
}
break;
case OP_RES:
case OP_RTI:
default:
abort();
break;
}
}
restore_input_buffering();
}

至此为止,对LC-3的虚拟机仿真结束

最近一直在学习,但是却忽略了最基本的需求——想要有所反馈,所以的话找了几个项目来做一做,这里的话我挑选的是一个用C语言实现shell的项目,用的是Linux编程,刚好我还没有试过在WSL上的编程开发,所以试一试呀,顺便把这个英语博客翻译一遍,锻炼下水平。

项目地址:[brenns10/lsh: Simple shell implementation. Tutorial here ->]

LinuxShell

Shell 的生命周期

Shell 在其生命周期中主要有以下三个动作:

  • 初始化:

    在这一步中,一个典型的Shell会读取并执行它的配置文件,从而引导shell的行为。

  • 解释:

    接着shell会从标准输入(stdin)中读取并执行你的输入(这里的输入还可以是文件等内容)

  • 终端:

    当程序被执行之后,Shell需要负责关闭程序,释放内存,并将其终止

这几个步骤在许多程序开发中都是适用的,但是我们将使用他们作为我们的Shell程序的基础。我们的程序十分简单,不需要任何配置文件,也不需要任何关闭命令。实际上,我们只需要循环调用并终止它们。当然在我们的程序架构中,循环并不是最为重要的。

1
2
3
4
5
6
7
8
9
10
11
int main(int argc,char ** argv){
//加载配置文件(如果有的话)

//执行循环指令
lsh_loop();

//关闭清理操作

return EXIT_SUCCESS;

}

这里你可以看到实际上就一个函数,它将不停的循环解释命令。我们接下来来完成lsh_loop()的实现

Shell中的基础循环

我们已经考虑完了程序的启动。现在,我们需要搞清楚程序的逻辑,在一次又一次的循环中,我们需要做什么?实际上,我们只需要实现三个步骤以处理这些命令:

  • 读取:从标准输入中读取命令
  • 解析:分割命令,将其分为程序与参数
  • 运行:运行解析后的命令

接下来,让我们一点点实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void lsh_loop(){
char *line;
char **args;
int status;

do {
printf("> ");
line = lsh_read_line();
args = lsh_spilt_line(line);
status = lsh_execute(args);

free(line);
free(args);
} while (status);
}

我们看这个程序,前几行是几个变量的声明。这个DO-WHILE循环则是用来方便用来对status变量的检查,因为在每次循环后都会检查一遍它的值。在循环中,我们打印一个提示符,然后读取一行,使用函数来将其划分为关键词,然后再去执行他们。最终我们释放读取的行字符和我们创建的参数变量。在这里我们利用lsh_execute()返回的的status来决定何时退出循环。

行读取

从标准输入中读取一行看起来很容易,但是在C里面实现起来很困难1,因为你并不知道用户会往里面输入多长的内容。你不能只是简单的分配一个内存块,然后祈祷用户的输入不会超出内存块的大小。相反,你需要从一个内存块开始,如果超出了它们,就再次分配一个空间给他们。这是在C中常用的一种策略,我们在下面实现它:

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
char *lsh_read_line(){
int bufsize = LSH_RL_BUFSIZE;
int position = 0;
char *buffer = malloc(sizeof(char) * bufsize);
int c;

if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

while (1){
//读取字符
c = getchar();

//确定结束条件
if(c == EOF || c == '\n'){
buffer[position] = '\0';
return buffer;
}else{
buffer[position] = c;
}
position++;

//如果超出缓冲区,则拓展新的缓冲区
if (position >= bufsize){
bufsize += LSH_RL_BUFSIZE;
buffer = realloc(buffer,bufsize);
if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}
}
}

前面是一些声明。不过你不用太过在意他们,我更喜欢使用旧的C语言风格——在代码块的前面声明变量。而这个函数的核心则是在循环部分。在循环中我们读取字符(我们将其存储为整数型,而不是一个字符,在比较EOF时,是整型的比较2这是C语言新手经常会犯的错误),如果是回车或者EOF,我们终止并返回它,反之则继续添加字符。

接着,我们需要知道接下来的字符会不会超出缓冲区的大小。如果会的话我们就重新申请一块空间,然后再继续(要注意是否声明出错哦),这样就解决了。

熟悉最新的C语言标准的读者可能注意到,我们写的这个程序实际上已经有实现的函数了getline()。这个函数是GNU对C语言库的拓展。用它实现会更加的容易,不过上面的程序能帮你更好的理解这个函数的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
char *lsh_read_line(){
char *line = NULL;
ssize_t bufsize = 0;
if (getline(&line,&buffer,stdin) == -1){
if (feof(stdin)){
exit(EXIT_SUCCESS); //读取到了EOF
}else{
perror("readline");
exit(EXIT_FAILURE);
}
}
return line;
}

解析行

OK我们将目光放回循环中,我们已经实现了对它的读取,现在我们需要将其解释为一个参数列表。我们尽可能的简化它,所以在这里我们不支持引号和反斜杠转义在我们的命令行参数中。相反,我们将简单的使用空格来将参数一一分隔开来。所以命令echo "this message"并不会打印出this message,而是打印出thismessage

通过这些简化,我们用空格将他们划分为token,我们可以用标准库中的strtok函数实现

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
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"

char **lsh_split_line(char *line){
int bufsize = LSH_TOK_BUFSIZE,position = 0;
char **tokens = malloc(bufsize * sizeof(char*));
char *token;

if(!token){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

token = strtok(line,LSH_TOK_DELIM);
while(token != NULL){
tokens[position] = token;
position++;

if (position >= bufsize){
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens,bufsize * sizeof(char*));
if(!tokens){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}

token = strtok(NULL,LSH_TOK_DELIM);

}
tokens[position] = NULL;
return tokens;
}

也许你会发现这些代码长得很像,当然如此!我们使用相同的策略实现缓冲区的动态分配,不过这一次我们使用的是以NULL结尾的指针数组,而不是以’\0’结尾的字符数组

在函数的开始,通过对strtok()的调用,我们返回第一个标记的地址,实际上返回的是你给的字符串的地址。且用’\0’替换分隔符号,接着将每一个指针存放在指向字符的指针数组中

如果有必要的话,我们会重新分配指针数组的大小,直到没有token返回,此时终止读取

当我们得到了这些token和存储它的数组,我们要准备解析它了。接下来面临一个问题,我们该怎么做呢?

Shell如何启动进程

现在,我们已经来到了Shell的核心问题——启动进程。写一个Shell程序意味着你需要知道一个进程做了什么,以及怎么去启动它。接下来我们将简单的讨论一下类Unix系统中的进程

在Unix中只有两种方式去启动一个进程。第一种(几乎不算)是Init,当Unix系统启动时,它的内核被加载。当它的内核被加载并初始化时,内核将启动一个进程,即Init。这个进程一直运行在计算机的运行过程中,它负责加载那些其他的计算机所要用到的进程。

由于大多数进程并不是Init,所以实际上只剩下另一种启动进程的方法:系统调用。当调用一个函数时,操作系统复制进程并将他们运行。其中原来的进程被称之为“父进程”,新的进程被称之为“子进程”。将0返回给子进程并将子进程的PID返回给父进程。实际上,这意味着启动新进程的方式就是复制现有的进程。(fork)

这面临着一些问题。典型的,当你想要运行一个新的进程,你并不仅仅想要运行一个一样的进程,而是运行一个不一样的程序。这就是系统调用的意义所在。它用一个全新的程序替换当前正在运行的程序,这意味着当你调用时,操作系统将停止你的进程,加载新程序,并在其位置启该程序。进程不会从调用中返回(除非它发生了错误)(exec)

通过着两种系统调用,我们有了大多数程序在Unixs上运行的基本要素。首先一个现有的进程分成两个单独的部分。然后子进程将被替换成一个新的进程,父进程可以继续做其他的事情,甚至可以通过系统调用继续对子进程进行访问。

讲了这么多,现在,可以终于可以尝试编写一段启动代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int lsh_launch(char **args){
pid_t pid;
int status;

pid = fork();
if(pid == 0){
//子进程
if(execvp(args[0],args) == -1){
perror("lsh");
}
exit(EXIT_FAILURE);
}else if(pid < 0){
//错误的进程
perror("lsh");
}else{
//父进程
do{
wpid = waitpid(pid,&status,WUNTRACED);
}while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}

现在这个函数将使用我们先前解析的参数列表。然后分叉这个进程,并保存返回值(PID)。当fork()成功返回,此时我们有两个并发的进程。子进程将进入判断逻辑的第一种情况。

在子程序中我们希望能够运行用户的命令。所以我们使用exec系统调用的变种之一execvp。和exec的其他几个变种实现的效果有略微的不同。有的变种需要可变长度的字符串参数,有的则需要字符串列表,还有的需要指定进程使用的环境。而execvp()需要一个程序名称和一个字符串参数的列表(第一个参数必须是程序的名称,而不应该是程序的文件路径),当我们将程序名称传递给它,操作系统将在系统文件路径中去查询它

如果解析命令返回了-1(实际上可能返回其他的),那么说明产生了错误。此时我们使用perror打印系统的错误信息,前面加上程序的名称,以便于用户知道错误产生于哪里。然后我们退出当前进程以确保程序能继续运行

第二种判断情况说明fork()产生了错误。如果确实如此我们向用户返回错误信息,并确认是否需要终止。

第三种判断情况意味着fork()的子进程创建很成功。此时父进程将待命,我们知道子进程正在运行当前进程,因此父进程需要等待命令的完成。我们使用waitpid()等待进程的状态改变。不过进程的改变可能是由于各种原因,不仅仅只是因为进程终止了,进程可能是因为正常退出了,也可能是因为错误的代码,或是因为信号的终止。所以我们使用waitpid()提供的宏定义等待程序的退出或终止。当函数最终返回1,这意味着我们继续读取命令并执行了

内置函数

你也许会注意到lsh_loop()调用了lsh_execute(),但是在上面,我却又命名了一个函数lsh_launch,这是故意的。你看,大多数的命令在Shell中被解析为程序,但并不是所有,有一些是直接写入Shell中的

原因很简单。如果你想要更改当前所在的目录。你需要使用函数chdir(),问题是文件目录是当前进程的一个属性,所以如果你要使用cd改变当前目录的话,它将改变你当前进程的文件目录,父进程的目录将会保持不变。相反的是,Shell进程本身需要调用chdir()来更新它的目录。然后再启动子进程,它将继承当前的文件目录

相似的,exit程序,它将无法退出调用它的Shell程序,所以这个程序也需要内置在Shell中。当然,许多Shell程序都是通过配置运行配置文件进行的,比如~/.bashrc这些脚本使用更改Shell操作的命令。不过这些命令只有在 shell 进程本身内部实现时才能更改 shell 的操作。

所以这意味着我们需要添加一些Shell本身的命令。这里我们添加cd,exithelp程序,以下是它的实现:

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
//内置函数
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);
//内置指令
char *builtin_str[] = {
"cd",
"help",
"exit"
};
//函数指针
int (*builtin_func[])(char **)={
&lsh_cd,
&lsh_help,
&lsh_exit
};

int lsh_num_builtins(){
return sizeof(builtin_str) / sizeof(char *);
}

int lsh_cd(char **args){
if(args[1] == NULL){
fprintf(stderr,"lsh: expected argument to \"cd\"\n");
}else{
if(chdir(args[1]) != 0){
perror("lsh");
}
}
return 1;
}

int lsh_help(char **args){
int i;
printf("Stephen Brennan's LSH\n");
printf("Type program names and arguments,and hit enter.\n");
printf("The following are built in:\n");

for(i=0;i<lsh_num_builtins();i++){
printf(" %s\n",builtin_str[i]);
}

printf("Use the man command for information on other programs.\n");
return 1;
}

int lsh_exit(char **args){
return 0;
}

这一部分的代码有三个部分,第一部分包含了对函数的声明。提供函数原型是为了你可以使用它(即不定义函数内容,但声明后可以使用)。这是因为我们我们使用lsh_help()时,用到了内置函数数组,这个函数中包含了lsp_help(),如果想要打破这个循环则需要一开始就提供函数原型。

第二部分则是一个字符串数组,存储了函数的名称。后面跟了对应的函数指针的数组。这样将来就可以通过编辑函数数组来实现向其中添加功能,而且可以避免编写大型的switch程序。如果你对builtin_func的作用有所疑惑的话,那没问题!我也是。这是一个函数指针数组(它们都需要提供一个字符串数组,然后返回一个整数型的数值)任何涉及C语言中函数指针的声明都会变得十分复杂。我每次都要搜一下怎么声明3

最终,我们实现了这几个函数功能。lsh_cd函数首先检查第二个参数是否存在,如果不存在的话就打印错误信息。然后调用chdir()检查是否有误并返回。而lsp_help函数则是打印了作品的信息,还有内置的函数功能。最后lsp_exit函数返回0,作为终止循环的信号

分析

最后的一部分就是实现lsh_execute(),这个函数将用来调用内置函数,或是用来启动Shell进程,如果你读到了这里,你就知道我们为自己设置了多么简单的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int lsh_execute(char **args){
int i;
if(args[0] == NULL){
//空指令,直接返回
return 1;
}
for(i=0;i<lsh_num_builtins();i++){
if(strcmp(args[0],builtin_str[i]) == 0)
return (*builtin_func[i])(args);
}

return lsh_launch(args);
}

这些操作实际上是为了检查你输入的命令是否等于内置函数,如果是的话,就调用它,如果不是的话,将会调用lsh_launch()去启动这个进程。如果用户输入了一个空字符串,则会被警告参数为NULL。所以我们需要检查输入

整合

以上就是Shell所有的程序了。如果你耐心的读完了,那么你已经完全理解了Shell是怎么实现的了,快去试试吧。你只需要将这些代码复制到一个文件中,然后编译它。将你需要的文件头包含在里面。接下来我将提供我们从文件头中所用到的函数:

  • #include <sys/wait.h>
    • waitpid() and its macros
  • #include <unistd.h>
    • chdir()
    • fork()
    • exec()
    • pid_t
  • #include <stdlib.h>
    • malloc()
    • realloc()
    • free()
    • exit()
    • execvp()
    • EXIT_SUCCESS,EXIT_FAILURE
  • #include <stdio.h>
    • fprintf()
    • pintf()
    • stderr
    • getchar()
    • perror()
  • #include <string.h>
    • strcmp()
    • strtok()

你可以在开头的链接找到这篇文章的出处和源代码。


翻译加复现和学习差不多花了两天,感觉很有收获,很多地方翻译的不是很好,但是大致能理解。就我个人的感受而言,我感觉看英文的话勉强可以看懂是啥意思,但是翻译成中文的话总是不能很好的表达出来,可能这也和专业水平有关吧(加上我的英语不是很好)。我觉得这是一篇很好的文章,尤其是其中关于进程的部分让我学到了很多东西。我自己也跟着复现了一遍,为此我还配置了我的neovim(真的好帅),不过由于语法检测没有配置好,所以最后编译的时候还是一堆问题,磕磕绊绊的,但是还是让它成功的跑起来了,很有成就感!

最后附上我完整的程序(作者的信息我并没有修改,毕竟是一次复现)

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define LSH_RL_BUFSIZE 1024
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"

//内置函数
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);
//内置指令
char *builtin_str[] = {
"cd",
"help",
"exit"
};
//函数指针
int (*builtin_func[])(char **)={
&lsh_cd,
&lsh_help,
&lsh_exit
};

void lsh_loop();
char *lsh_read_line();
char **lsh_split_line(char *line);
int lsh_launch(char **args);
int lsh_execute(char **args);
int lsh_num_builtins();

int main(int argc,char ** argv){
//加载配置文件(如果有的话)

//执行循环指令
lsh_loop();

//关闭清理操作

return EXIT_SUCCESS;
}

void lsh_loop(){
char *line;
char **args;
int status;

do {
printf("> ");
line = lsh_read_line();
args = lsh_split_line(line);
status = lsh_execute(args);

free(line);
free(args);
} while (status);
}

char *lsh_read_line(){
int bufsize = LSH_RL_BUFSIZE;
int position = 0;
char *buffer = malloc(sizeof(char) * bufsize);
int c;

if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

while (1){
//读取字符
c = getchar();

//确定结束条件
if(c == EOF || c == '\n'){
buffer[position] = '\0';
return buffer;
}else{
buffer[position] = c;
}
position++;

//如果超出缓冲区,则拓展新的缓冲区
if (position >= bufsize){
bufsize += LSH_RL_BUFSIZE;
buffer = realloc(buffer,bufsize);
if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}
}
}

/*
//getline实现行读取
char *lsh_read_line(){
char *line = NULL;
ssize_t bufsize = 0;
if (getline(&line,&buffer,stdin) == -1){
if (feof(stdin)){
exit(EXIT_SUCCESS); //读取到了EOF
}else{
perror("readline");
exit(EXIT_FAILURE);
}
}
return line;
}
*/

char **lsh_split_line(char *line){
int bufsize = LSH_TOK_BUFSIZE,position = 0;
char **tokens = malloc(bufsize * sizeof(char*));
char *token;

if(!token){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

token = strtok(line,LSH_TOK_DELIM);
while(token != NULL){
tokens[position] = token;
position++;

if (position >= bufsize){
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens,bufsize * sizeof(char*));
if(!tokens){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}

token = strtok(NULL,LSH_TOK_DELIM);

}
tokens[position] = NULL;
return tokens;
}

int lsh_launch(char **args){
pid_t pid;
int status;

pid = fork();
if(pid == 0){
//子进程
if(execvp(args[0],args) == -1){
perror("lsh");
}
exit(EXIT_FAILURE);
}else if(pid < 0){
//错误的进程
perror("lsh");
}else{
//父进程
do{
waitpid(pid,&status,WUNTRACED);
}while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}

int lsh_num_builtins(){
return sizeof(builtin_str) / sizeof(char *);
}

int lsh_cd(char **args){
if(args[1] == NULL){
fprintf(stderr,"lsh: expected argument to \"cd\"\n");
}else{
if(chdir(args[1]) != 0){
perror("lsh");
}
}
return 1;
}

int lsh_help(char **args){
int i;
printf("Stephen Brennan's LSH\n");
printf("Type program names and arguments,and hit enter.\n");
printf("The following are built in:\n");

for(i=0;i<lsh_num_builtins();i++){
printf(" %s\n",builtin_str[i]);
}

printf("Use the man command for information on other programs.\n");
return 1;
}

int lsh_exit(char **args){
return 0;
}

int lsh_execute(char **args){
int i;
if(args[0] == NULL){
//空指令,直接返回
return 1;
}
for(i=0;i<lsh_num_builtins();i++){
if(strcmp(args[0],builtin_str[i]) == 0)
return (*builtin_func[i])(args);
}

return lsh_launch(args);
}


  1. 在作者那个时候可能没有getline()不过后文有补充↩︎

  2. 我一开始不信,尝试并了解了一下确实如此,getchar()返回的是一个整数↩︎

  3. 作者在这里补充了一句,距离写这个文章之后过去了6年。期间他仍然是以写C语言为主,但是每次遇到函数指针的问题,他还是得谷歌一下(函数指针真的怪怪的)↩︎

简单结束了数组和字符串的学习,现在来继续学习哈希表的内容

设计哈希表

设计哈希集合

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

不使用任何内建的哈希表库设计一个哈希集合(HashSet),实现 MyHashSet 类:

  • void add(key) 向哈希集合中插入值 key 。
  • bool contains(key) 返回哈希集合中是否存在这个值 key 。
  • void remove(key) 将给定值 key 从哈希集合中删除。如果哈希集合中没有这个值,什么也不做。
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
class MyHashSet(object):
def __init__(self):
self.capacity = 1e5
self.bucket = dict()

def add(self, key:int)->None:
if self.contains(key):
return
if key%self.capacity in self.bucket.keys():
self.bucket[key%self.capacity].append(key)
else:
self.bucket[key%self.capacity] = [key]

def remove(self, key:int)->None:
if not self.contains(key):
return
if key%self.capacity in self.bucket.keys():
if key in self.bucket[key%self.capacity]:
self.bucket[key%self.capacity].remove(key)

def contains(self, key:int)->bool:
if key % self.capacity in self.bucket.keys():
if key in self.bucket[key%self.capacity]:
return True
else:
return False
else:
return False

实际上这里是使用python的字典方法替代了链表的使用

设计哈希映射

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

不使用任何内建的哈希表库设计一个哈希映射(HashMap),实现 MyHashMap 类,MyHashMap() 用空映射初始化对象

  • void put(int key, int value) 向 HashMap 插入一个键值对 (key, value) 。如果 key 已经存在于映射中,则更新其对应的值 value 。
  • int get(int key) 返回特定的 key 所映射的 value ;如果映射中不包含 key 的映射,返回 -1 。
  • void remove(key) 如果映射中存在 key 的映射,则移除 key 和它所对应的 value 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyHashMap(object):
def __init__(self):
self.capcity = 1e5
self.bucket = dict()

def put(self, key:int, value:int)->None:
if not key%self.capcity in self.bucket.keys():
self.bucket[key%self.capcity] = {key:value}
else:
self.bucket[key%self.capcity][key] = value

def get(self, key:int)->int:
if key%self.capcity in self.bucket.keys():
if key in self.bucket[key%self.capcity].keys():
return self.bucket[key%self.capcity][key]
return -1


def remove(self, key:int)->None:
if not key%self.capcity in self.bucket.keys():
return
if key%self.capcity in self.bucket.keys():
if key in self.bucket[key%self.capcity].keys():
del self.bucket[key%self.capcity][key]

这里同样是使用字典实现的hash映射,要注意python中常见的用于处理字典的函数操作

哈希集合

哈希集合实际上是一个不包含重复元素的集合数据结构。它只存储元素本身,不存储与元素相关联的值。我们可以用python中的set()和dict()实现,他们实际上都是使用的hash存储结构

快乐数

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

编写一个算法来判断一个数 n 是不是快乐数。「快乐数」 定义为:

对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果这个过程 结果为 1,那么这个数就是快乐数。如果 n 是 快乐数 就返回 true ;不是,则返回 false 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution(object):
def isHappy(self, n:int)->bool:
temp = set()
while n!=1: # 用于确定计算新的平方和
next = 0
while n!=0:
first = n%10
next += first**2
n //=10
if next in temp: # 用于检查是否存在,如果存在说明不是快乐数
return False
else:
temp.add(next)
n = next # 记得要更新n的值
return True

这里的考察实际上是对于hash存储结构的使用的考察,我们在这里用的是set()

两个数组的交集

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给定两个数组 nums1nums2 ,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序

1
2
3
4
5
class Solution(object):
def intersection(self, nums1:List[int], nums2:List[int])->List[int]:
set1 = set(nums1)
set2 = set(nums2)
return [x for x in set1 if x in set2]

由于python的for ... in ...查找操作相当于对hash表的查找操作,所以直接快捷方式结束了

哈希映射

哈希映射是用于存储键值对结构的,也就是python中的字典结构

同构字符串

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给定两个字符串 s 和 t ,判断它们是否是同构的。

如果 s 中的字符可以按某种映射关系替换得到 t ,那么这两个字符串是同构的。

每个出现的字符都应当映射到另一个字符,同时不改变字符的顺序。不同字符不能映射到同一个字符上,相同字符只能映射到同一个字符上,字符可以映射到自己本身。

1
2
3
4
5
6
7
8
9
class Solution(object):
def isIsomorphic(self, s:str, t:str)->bool:
st,ts = {},{}
for x,y in zip(s,t):
if x in st and st[x] != y or y in ts and ts[y] != x:
return False
else:
st[x],ts[y] = y,x
return True

这里的zip()用于将多个可迭代对象(列表,数组等)打包成同一个对象,这里是将其打包为了一个字典对象

第一个唯一字符

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

给定一个字符串 s ,找到 它的第一个不重复的字符,并返回它的索引 。如果不存在,则返回 -1

1
2
3
4
5
6
7
class Solution:
def firstUniqChar(self, s: str) -> int:
frequency = collections.Counter(s)
for i, ch in enumerate(s):
if frequency[ch] == 1:
return i
return -1

???内置函数

我突然后悔了,我本来学算法是希望能学一点相关的算法知识,但是我报了Python 赛道,发现根本不是在学算法,感觉像是在学语法