从裸机启动开始运行一个C++程序(十五)

前序文章请看:
从裸机启动开始运行一个C++程序(十四)
从裸机启动开始运行一个C++程序(十三)
从裸机启动开始运行一个C++程序(十二)
从裸机启动开始运行一个C++程序(十一)
从裸机启动开始运行一个C++程序(十)
从裸机启动开始运行一个C++程序(九)
从裸机启动开始运行一个C++程序(八)
从裸机启动开始运行一个C++程序(七)
从裸机启动开始运行一个C++程序(六)
从裸机启动开始运行一个C++程序(五)
从裸机启动开始运行一个C++程序(四)
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)

艰辛的C++程序

趁热打铁

上一章的最后咱们已经成功把C++文件链接进Kernel了,趁热打铁,我们用C++语法来实现绘制图形的功能,比如我们可以将绘制点和绘制矩形的方法封装成类,通过调用SetVMem进行操作。代码如下:

extern "C" { // 由于这些库都是C方式的,因此需要额外声明
  #include <stdint.h>
  extern void SetVMem(long addr, uint8_t data); 
}

constexpr int screen_width = 320;
constexpr int screen_length = 200;

class Point {
 public:
  Point(int x, int y);
  ~Point() = default;
  void Draw(uint8_t color) const;

 private:
  int x_, y_;
};

Point::Point(int x, int y): x_(x), y_(y) {}

void Point::Draw(uint8_t color) const {
  SetVMem(y_ * screen_width + x_, color);
}

class Rect {
 public:
  Rect(int x, int y, int width, int length);
  ~Rect() = default;
  void Draw(uint8_t color) const;

 private:
  int x_, y_, width_, length_;
};

Rect::Rect(int x, int y, int width, int length): x_(x), y_(y), width_(width), length_(length) {}

void Rect::Draw(uint8_t color) const {
  for (int i = 0; i < width_; i++) {
    for (int j = 0; j < length_; j++) {
      Point{x_ + i, y_ + j}.Draw(color);
    }
  }
}

extern "C"
int main() {
  Rect{10, 10, 50, 30}.Draw(0x26);
  return 0;
}

运行效果

目前功能都没有问题,接下来我们要做一下工程源码的整理。

C库的C++改造

由于我们要将C库引入到C++代码中,所以如果都是在C++中显式使用extern "C"就会很麻烦,因此好的做法是,把这种差别体现在头文件中,无论是C语言还是C++都可以直接使用。

编译器用于区别C还是C++源码的方法是通过一个编译宏__cplusplus,这个宏同时还表示了C++版本。因此,我们在头文件中进行判断,如果含有这个宏,就自动添加extern "C",否则不添加。也就是这样:

#ifdef __cplusplus
extern "C" {
#endif

// 这里是头文件的实际内容
// ...

#ifdef __cplusplus
}
#endif

我们将C库中的所有头文件都按这种方式改造,并且把SetVMem函数也提供在stdio.h中。这样,对于main.cpp来说,只需要正常引入头文件即可。

将图形绘制相关代码独立

我们把挤在main.cpp中的图形绘制相关代码单独抽出去,创建graphic_ui.hppgraphic_ui.cpp文件。

// graphic_ui.hpp
#pragma once
#include <stdint.h>

namespace ui {

constexpr int screen_width = 320;
constexpr int screen_length = 200;

class Point {
 public:
  Point(int x, int y);
  ~Point() = default;
  void Draw(uint8_t color) const;

 private:
  int x_, y_;
};

class Rect {
 public:
  Rect(int x, int y, int width, int length);
  ~Rect() = default;
  void Draw(uint8_t color) const;

 private:
  int x_, y_, width_, length_;
};

}
// graphic_ui.cpp
#include "graphic_ui.hpp"
#include <stdio.h>

namespace ui {

Point::Point(int x, int y): x_(x), y_(y) {}

void Point::Draw(uint8_t color) const {
  SetVMem(y_ * screen_width + x_, color);
}

Rect::Rect(int x, int y, int width, int length): x_(x), y_(y), width_(width), length_(length) {}

void Rect::Draw(uint8_t color) const {
  for (int i = 0; i < width_; i++) {
    for (int j = 0; j < length_; j++) {
      Point{x_ + i, y_ + j}.Draw(color);
    }
  }
}

}

同时,加上对应的makefile

.PHONY: all
all: kernel_final.bin

kernel.o: kernel.nas
	nasm kernel.nas -f elf64 -o kernel.o

graphic_ui.o: graphic_ui.cpp graphic_ui.hpp
	x86_64-elf-g++ -c -std=c++17 -m64 -march=x86-64 -fno-builtin -I../libc/include graphic_ui.cpp -o graphic_ui.o -Wall -Werror -Wextra

main.o: main.cpp graphic_ui.hpp
	x86_64-elf-g++ -c -std=c++17 -m64 -march=x86-64 -fno-builtin -I../libc/include main.cpp -o main.o -Wall -Werror -Wextra

entry.o: entry.c ../libc/include/stdio.h
# 需要用-I制定头文件扫描位置
	x86_64-elf-gcc -c -m64 -march=x86-64 -fno-builtin -I../libc/include entry.c -o entry.o -Wall -Werror -Wextra

../libc/libc.a:
	pushd ../libc && $(MAKE) clean && $(MAKE)  libc.a && popd

kernel_final.out: kernel.o entry.o main.o graphic_ui.o ../libc/libc.a 
# 需要用-L指定静态链接库位置
# -lc表示链接libc.a
# 注意kernel.o要放在第一个
	x86_64-elf-ld -m elf_x86_64 -Ttext=0x8000 kernel.o entry.o main.o graphic_ui.o -L../libc -lc -o kernel_final.out

kernel_final.bin: kernel_final.out
	x86_64-elf-objcopy -I elf64-x86-64 -S -R ".eh_frame" -R ".comment" -O binary kernel_final.out kernel_final.bin

.PHONY: clean
clean:
	-rm -f .DS_Store
	-rm -f *.bin 
	-rm -f *.o
	-rm -f *.out

主函数则改造为:

#include <stdint.h> // 改造后的头文件可以直接引用
#include "graphic_ui.hpp"

extern "C"
int main() {
  ui::Rect{10, 10, 50, 30}.Draw(0x26);
  return 0;
}

至此的项目源码放在附件(15-1)中,读者可自行验证。

绘制圆

已经有了绘制点和矩形的类了,我们想再添加一个绘制圆形的类。圆需要圆心和半径来确定,而圆形就是一个点,因此这里我们正好可以测试一下类的组合。代码如下:

// graphic_ui.hpp
class Circle {
 public:
  Circle(const Point &center, int radium);
  ~Circle() = default;
  void Draw(uint8_t color) const;

 private:
  Point center_;
  int radium_;
};

// graphic_ui.cpp
Circle::Circle(const Point &center, int radium): center_(center), radium_(radium) {}

void Circle::Draw(uint8_t color) const {
  // 采用点阵扫描的方法,沿着x轴,从(c.x - r, c.y)开始,一直绘制到(c.x + r, c.y)
  // 中间横坐标每增加1,就计算当前横坐标上,符合(x-c.x)²+(y-c.y)²≤r²的纵坐标值,并绘制颜色
  for (int x = center_.x - radium_; x <= center_.x + radium_; i++) {
    // y = c.y±√(r²-(x-c.x)²)
    int y1 = center_.y - ::sqrt(radium_ * radium_ - (x - center_) * (x - center_));
    int y2 = center_.y + ::sqrt(radium_ * radium_ - (x - center_) * (x - center_));
    for (int y = y2; y < y1; y++) {
        Point{x, y}.Draw(color);
    }
  }
}

由于这里需要开平方的能力,因此我们在C库中添加math.hmath.c,同时实现sqrt函数:

// math.h
#ifdef __cplusplus
extern "C" {
#endif
#include "stdint.h"

int abs(int n);
int sqrt(int n);

#ifdef __cplusplus
}
#endif
// math.c
#include "include/math.h"

int abs(int n) {
  if (n < 0) {return -n;}
  return n;
}

int sqrt(int n) {
  if (n < 0) {return 0;}
  // 由于是整数,直接暴力尝试
  for (int i = 0; i < n; i++) {
    if (i * i <= n && (i + 1) * (i + 1) >= n) {
        return i;
    }
  }
  
  return 0;
}

之所以这里用整型,主要是当前没有配置浮点型的相关运算,在Intel体系中,浮点运算是又x87部件运行的,所以这部分都有单独的运行指令,而我们没有做相关配置,所以只要程序中出现浮点型,就会执行失败。不过当前需求下整型也完全够用了,所以这里先用整型。

主函数中绘制圆看看结果:

#include <stdint.h>
#include "graphic_ui.hpp"

extern "C"
int main() {
  ui::Circle{{100, 100}, 80}.Draw(0x23);

  return 0;
}

效果如下:
运行结果
圆已经可以绘制出来了,但这个返回值为什么是35呢?看来上了64位以后,变参的获取方式也存在了一些问题。之前我们在stdarg.h中是这样定义的:

#define va_start(varg_ptr, last_val) (varg_ptr = ((uint8_t *)&last_val + sizeof(last_val)))

在64位环境下这个写法是有问题的,原因很简单,我们之前提到过,因为在64位环境下,函数参数并不是全部压栈的,而是优先进入寄存器。虽然,为了解析这些参数,编译器还是会把它们重新入栈,但有一个严重的问题,就是last_val和真实变参并不是连续的。

比如,我们定义如下变参函数:

void Demo(int a, ...) {}

汇编后是:

Demo(int, ...):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 72
        mov     DWORD PTR [rbp-180], edi ; a
        mov     QWORD PTR [rbp-168], rsi ; arg1
        mov     QWORD PTR [rbp-160], rdx
        mov     QWORD PTR [rbp-152], rcx
        mov     QWORD PTR [rbp-144], r8
        mov     QWORD PTR [rbp-136], r9
        test    al, al
        je      .L3
        movaps  XMMWORD PTR [rbp-128], xmm0
        movaps  XMMWORD PTR [rbp-112], xmm1
        movaps  XMMWORD PTR [rbp-96], xmm2
        movaps  XMMWORD PTR [rbp-80], xmm3
        movaps  XMMWORD PTR [rbp-64], xmm4
        movaps  XMMWORD PTR [rbp-48], xmm5
        movaps  XMMWORD PTR [rbp-32], xmm6
        movaps  XMMWORD PTR [rbp-16], xmm7
.L3:
        nop
        leave
        ret

可以看到a与第一个变参之间并不是差64位。而且,这个值会随着Demo中局部变量的增加而改变。

因此,在64位环境下,我们不能在通过简单的宏定义来完成,编译器会把变参从寄存器中,先取出来放在栈内的某一个空间(比如上例中的rbp-168),然后当调用va_arg时,再把指针指向对应的参数位置。

由于在这种场景下,语言标准并没有定义这些参数从寄存器中取出来后如何布局,因此这些行为完全由编译器来决定。编译器自身实现了这些变参的解析功能,所以,我们直接调用编译器的内建函数:

// 通过编译器内建功能来完成
typedef __builtin_va_list va_list;
#define va_start(v, l) __builtin_va_start(v, l)
#define va_arg(v, t) __builtin_va_arg(v, t)
#define va_end(v) __builtin_va_end(v)

而具体的__builtin方法的实现,交由编辑器即可。

所以,改造完这个以后我们再看看运行结果:
运行效果

这个小bug也解决了。

至此,工程源码将会在附件(15-2)中,供读者参考。

虚函数链接问题

在编写图形渲染类的时候大家应该能够发现一个问题,就是Point,Rect,Circle都属于「图形」,并且都实现了用于渲染的Draw方法,因此,按照OOP设计,它们应当同属一个父类。

因此我们抽象一个Shape父类,将Draw方法改为虚函数。代码如下:

#pragma once
#include <stdint.h>

namespace ui {

constexpr int screen_width = 320;
constexpr int screen_length = 200;

class Shape {
 public:
  virtual void Draw(uint8_t color) const = 0;
};

class Point : public Shape {
 public:
  Point(int x, int y);
  ~Point() = default;
  void Draw(uint8_t color) const override;
  int x() const {return x_;}
  int y() const {return y_;}

 private:
  int x_, y_;
};

class Rect : public Shape {
 public:
  Rect(int x, int y, int width, int length);
  ~Rect() = default;
  void Draw(uint8_t color) const override;

 private:
  int x_, y_, width_, length_;
};

class Circle : public Shape {
 public:
  Circle(const Point &center, int radium);
  ~Circle() = default;
  void Draw(uint8_t color) const override;

 private:
  Point center_;
  int radium_;
};

}

不过这时,构建的时候就会发现以下报错:

x86_64-elf-ld: graphic_ui.o:(.rodata._ZTIN2ui6CircleE[_ZTIN2ui6CircleE]+0x0): undefined reference to `vtable for __cxxabiv1::__si_class_type_info'
x86_64-elf-ld: graphic_ui.o:(.rodata._ZTIN2ui4RectE[_ZTIN2ui4RectE]+0x0): undefined reference to `vtable for __cxxabiv1::__si_class_type_info'
x86_64-elf-ld: graphic_ui.o:(.rodata._ZTIN2ui5PointE[_ZTIN2ui5PointE]+0x0): undefined reference to `vtable for __cxxabiv1::__si_class_type_info'
x86_64-elf-ld: graphic_ui.o:(.rodata._ZTIN2ui5ShapeE[_ZTIN2ui5ShapeE]+0x0): undefined reference to `vtable for __cxxabiv1::__class_type_info'

报错是链接阶段的,说是没有找到__cxxabiv1::__si_class_type_info__cxxabiv1::__class_type_info的虚函数表。那这又是个什么东西呢?

从命名上我们可以得知,这玩意属于「C++ ABI v1」,也就是Application Binary Interface,应用程序二进制接口。也就是说,这应当是OS为App所实现的通用接口。作为应用程序App,在构建时,会依赖操作系统提供的这些接口。

上面缺少的type_info相关信息,就是C++ App在运行时RTTI(Run-Time Type Identification)所使用的一些类型信息。

正常来说,ABI的实现都在libc++库中,由对应的OS来提供。但这件事奇怪的点就在于,我们当前就是内核程序,并不是App,谁来提供ABI呢?显然,也只有我们自己了。

不过既然我们目前并没有RTTI的需求,所以我们构造一个假的,只要能让链接器找到就好了。代码如下:

namespace __cxxabiv1 {
  struct __si_class_type_info {
    virtual void f() {} // 必须有一个虚函数,才能构建虚函数表
  } ins1; // 必须至少有一个对象实例,才能促使类型构建虚函数表

  struct __class_type_info {
    virtual void f() {}
  } ins2;
}

这样再重新构建,发现正常了,运行结果如下:
运行结果

当然,这只是目前需求的做法,如果你真的想继续使用C++的其他功能,那对应的ABI还是要好好实现的。这也是很多人说C++并不适合写内核,原因就在这,它并不像C那样纯粹,必须依赖很多额外的东西才能够正常构建,而在写内核的时候这些东西往往是缺失的。

目前的项目源码将会在附件(15-3)中,供读者参考。

小结

我们用了15篇的篇幅,从x86架构的裸机启动开始,成功运行了一个C++程序,并且是内核态的。

下一篇将会是完结篇,我们将会总结和归纳整个系列,还会列举通过这件事情我们可以分析出的C++的一些理念,以及笔者个人的心得体会。

本篇的实例将会在附件(demo_code_15)中,供读者参考。

从裸机启动开始运行一个C++程序(十六)

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

borehole打洞哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值