前序文章请看:
从裸机启动开始运行一个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.hpp
和graphic_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 ¢er, int radium);
~Circle() = default;
void Draw(uint8_t color) const;
private:
Point center_;
int radium_;
};
// graphic_ui.cpp
Circle::Circle(const Point ¢er, 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.h
和math.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 ¢er, 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)中,供读者参考。