前序文章请看:
从裸机启动开始运行一个C++程序(十一)
从裸机启动开始运行一个C++程序(十)
从裸机启动开始运行一个C++程序(九)
从裸机启动开始运行一个C++程序(八)
从裸机启动开始运行一个C++程序(七)
从裸机启动开始运行一个C++程序(六)
从裸机启动开始运行一个C++程序(五)
从裸机启动开始运行一个C++程序(四)
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)
重新整理工程文件
到目前为止,我们工程中的源文件有两类,一类是.nas
结尾的汇编代码,另一类是.c
结尾的C语言代码,他们之间还有互相调用的关系(kernel.nas
调用entry.c
,而entry.c
会调用asm_fun.nas
)。
同时,我们把跟MBR和Kernel头部的部分,以及管理C程序相关的库都是混在一起写的,后续如果工程再复杂起来,会显得比较凌乱不好管理。所以,在继续之前,我们先做这么几件事:
- 把函数声明、结构体定义等收纳到头文件中管理。
- 分离MBR、Kernel、C库相关部分,在独立路径中管理(并编写对应的
makefile
) - 将C库的部分先整理为静态链接库(
lib
),之后再参与编译。
整理后的路径如下:
这里调整后的路径会上传到附件中(12-1),建议读者可以对照着工程来看。
根目录下我们保留bochsrc
,这是配置虚拟机的。然后里面分别有mbr
、kernel
和libc
三个路径。前两个无需解释,后面这个libc
就是我们把一些C库相关的东西写在这个路径里,而kernel逻辑相关的则是放在kernel
路径下,例如kernel/entry.c
与此同时,我们将挤在entry.c
当中的putchar
、puts
相关逻辑转移至libc/stdio.c
中,并且在libc/include/stdio.h
中进行声明。
之后,在编译选项中通过-I
参数,可以将默认的头文件搜索路径定向到libc/include
中。并且针对libc
路径单独进行静态链接库打包,成为libc.a
。
最后,在链接选项中,通过-L
参数指定静态库搜索路径为libc
,并且通过-l
参数指定使用libc.a
静态库。
再次强调,请读者通过附件中的工程代码,仔细阅读一下改造后的工程布局和对应的makefile
。由于文章中不便表示这种路径调整的动作,因此不再在正文中引用代码,请读者通过工程实例来查看。
继续完善C库
接下来我们要继续完善C库,实现几个重要的功能,让代码库至少处于一个基本可用状态。
stddef
这个库主要是实现一些宏和类型定义:
// stddef.h
#ifndef NULL
#define NULL (void *)0
#endif
typedef unsigned size_t;
typedef unsigned uintptr_t;
stdint
这个库主要是实现一些定长整型。注意,当前我们是在32位环境下,日后切换到64位环境后要做一定的适配。
typedef char int8_t;
typedef short int16_t;
typedef int int32_t;
typedef long long int64_t;
typedef unsigned char uint8_t;
typedef short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
typedef int intptr_t;
typedef unsigned uintptr_t;
stdarg
这个库主要是服务于变参函数,因为我们想实现printf
,那关于变参的处理,是需要一些辅助工具的。
#include <stdint.h>
#include <stddef.h>
typedef uint8_t *va_list;
#define va_start(varg_ptr, last_val) (varg_ptr = (uint8_t *)(&last_val + 1))
#define va_arg(varg_ptr, type) (varg_ptr += sizeof(type), *((type *)varg_ptr - 1))
#define va_end(varg_ptr) (varg_ptr = NULL)
如果读者对这几个宏有疑惑的话,那我们可以换一个思路来考虑。所谓变参,其实就是从C语言编译器的层面上不限制函数参数罢了,但读取参数的方式是没变的,都是从ebp+8
开始是第一个参数,依次向上寻找。
所以,当我们确定了变参的头部以后,按照指针偏移向上寻找即可。所以va_list
就是变参头部的指针,va_start
用于确定变参头部的地址,而va_arg
则是通过参数的类型来获取数据,并且进行指针偏移。最后的va_end
是将指针清空。
稍后我们会利用它来实现printf
。
string
这里实现一些与字符串相关的工具,注意其实这些工具更适合用汇编直接来实现,但暂时这里先给出C语言实现的版本:
#include "include/string.h"
#include "include/stdint.h"
char *strcpy(char *dst, const char *src) {
char *p = dst;
while (*src != '\0') {
*p++ = *src++;
}
return dst;
}
size_t strlen(const char *str) {
size_t size = 0;
for (const char *p = str; *p != '\0'; p++) {
size++;
}
return size;
}
void *memcpy(void *dst, const void *src, size_t size) {
uint8_t *p = (char *)dst;
const uint8_t *q = (const uint8_t *)src;
for (int i = 0; i < size; i++) {
p[i] = q[i];
}
return dst;
}
void *memset(void *dst, int ch, size_t size) {
uint8_t *p = dst;
for (long i = 0; i < size; i++) {
p[i] = (uint8_t)ch;
}
return dst;
}
stdio
最后,咱们在stdio
上实现sprintf
和printf
,这里我们仅实现基本的格式符,复杂的(如%0.3f
)暂时不考虑,如果读者感兴趣可以自行实现。代码如下:
#include "include/stdio.h"
#include "include/string.h"
extern void SetVMem(long addr, unsigned char data);
#define STDOUT_BUF_SIZE 1024
// 定义光标信息
typedef struct {
long offset; // 暂时只需要一个偏移量
} CursorInfo;
static CursorInfo g_cursor_info = {0}; // 全局变量,保存光标信息
int putchar(int ch) {
if (ch == '\n') { // 处理换行
g_cursor_info.offset += 80 * 2; // 一行是80字符
g_cursor_info.offset -= ((g_cursor_info.offset / 2) % 80) * 2; // 回到行首
} else {
SetVMem(g_cursor_info.offset++, (unsigned char)ch);
SetVMem(g_cursor_info.offset++, 0x0f);
}
return ch;
}
int puts(const char *str) {
// 处理C字符串,需要向后找到0结尾,逐一调用putchar
for (const char *p = str; *p != '0'; p++) {
putchar(*p);
}
return 0;
}
static size_t int_to_string(char *res, int i, uint8_t base) {
if (base > 16 || base <= 1) {
return 0;
}
if (i == 0) {
res[0] = '0';
return 1;
}
int size = 0;
if (i < 0) {
res[0] = '-';
i *= -1;
size++;
}
int quo = i / base;
int rem = i % base;
// 利用函数递归栈逆向结果
if (quo != 0) {
size += int_to_string(res + size, quo, base);
}
if (rem >= 0 && rem <= 9) {
res[size] = (char)rem + '0';
size++;
} else if (rem <= 15) {
res[size] = (char)rem - 10 + 'a';
size++;
}
return size;
}
static size_t uint_to_string(char *res, unsigned i, uint8_t base) {
if (base > 16 || base <= 1) {
return 0;
}
if (i == 0) {
res[0] = '0';
return 1;
}
int size = 0;
int quo = i / base;
int rem = i % base;
// 利用函数递归栈逆向结果
if (quo != 0) {
size += int_to_string(res, quo, base);
}
if (rem >= 0 && rem <= 9) {
res[size] = (char)rem + '0';
size++;
} else if (rem <= 15) {
res[size] = (char)rem - 10 + 'a';
size++;
}
return size;
}
int vsprintf(char *str, const char *fmt, va_list li) {
const char *p_src = fmt;
char *p_dst = str;
while (*p_src != '\0') {
if (*p_src == '%') {
p_src++;
switch (*p_src++) {
case '%':
*p_dst++ = '%';
break;
case 'd':
p_dst += int_to_string(p_dst, va_arg(li, int), 10);
break;
case 'u':
p_dst += uint_to_string(p_dst, va_arg(li, unsigned), 10);
break;
case 'x':
p_dst += uint_to_string(p_dst, va_arg(li, unsigned), 16);
break;
case 'o':
p_dst += uint_to_string(p_dst, va_arg(li, unsigned), 8);
break;
case 'c':
*p_dst++ = (char)va_arg(li, int); // 4字节对齐
break;
case 's':
const char *str = va_arg(li, const char *);
strcpy(p_dst, str);
p_dst += strlen(str);
}
} else {
*p_dst++ = *p_src++;
}
}
return p_dst - str;
}
int sprintf(char *str, const char *fmt, ...) {
va_list li;
va_start(li, fmt);
int ret = vsprintf(str, fmt, li);
va_end(li);
return ret;
}
int vprintf(const char *fmt, va_list li) {
char buf[STDOUT_BUF_SIZE];
memset(buf, 0, sizeof(buf));
int ret = vsprintf(buf, fmt, li);
if (ret < 0) {
return ret;
}
for (const char *p = buf; *p != 0; p++) {
putchar(*p);
}
return ret;
}
int printf(const char *fmt, ...) {
va_list li;
va_start(li, fmt);
int ret = vprintf(fmt, li);
va_end(li);
return ret;
}
看一看效果
在构建之前还有一个问题要注意,咱们之前的代码在MBR里只读取了2个扇区,随着Kernel的逐渐增大,这个大小可能很快就超了,所以咱们要去MBR里改一下,让它多读几个扇区:
; LBA28模式,逻辑扇区号28位,从0x0000000到0xFFFFFFF
; 设置读取扇区的数量
mov dx, 0x01f2
mov al, 12 ; 读取连续的几个扇区,每读取一个al就会减1
out dx, al
; 设置起始扇区号,28位需要拆开
mov dx, 0x01f3
mov al, 0x02 ; 从第2个扇区开始读(1起始,0留空),扇区号0~7位
out dx, al
mov dx, 0x01f4 ; 扇区号8~15位
mov al, 0
out dx, al
mov dx, 0x01f5 ; 扇区号16~23位
mov al, 0
out dx, al
mov dx, 0x01f6
mov al, 111_0_0000b ; 低4位是扇区号24~27位,第4位是主从盘(0主1从),高3位表示磁盘模式(111表示LBA)
; 配置命令
mov dx, 0x01f7
mov al, 0x20 ; 0x20命令表示读盘
out dx, al
wait_finish:
; 检测状态,是否读取完毕
mov dx, 0x01f7
in al, dx ; 通过该端口读取状态数据
and al, 1000_1000b ; 保留第7位和第3位
cmp al, 0000_1000b ; 要检测第7位为0(表示不在忙碌状态)和第3位是否是1(表示已经读取完毕)
jne wait_finish ; 如果不满足则循环等待
; 从端口加载数据到内存
mov cx, 1024 * 12 / 2 ; 一共要读的字节除以2(表示次数,因为每次会读2字节所以要除以2)
mov dx, 0x01f0
mov ax, 0x0800
mov ds, ax
xor bx, bx ; [ds:bx] = 0x08000
read:
in ax, dx ; 16位端口,所以要用16位寄存器
mov [bx], ax
add bx, 2 ; 因为ax是16位,所以一次会写2字节
loop read
另一个就是,由于gcc在编译时会自动去寻找系统自带的C头文件,可能会造成编译时函数重复定义的报错,因此,我们还需要在每一个C文件的编译指令加一个-fno-buildin
参数,例如:
entry.o: entry.c ../libc/include/stdio.h
x86_64-elf-gcc -c -m32 -march=i386 -fno-builtin -I../libc/include entry.c -o entry.o
最后我们在Entry()
中调用printf
:
#include <stdio.h>
void Entry() {
const char *data = "ABC123~~";
int a = 6;
printf("Hello, World!\n%s\n%d", data, a);
}
运行结果如下:
至此,咱们已经基本实现从裸机启动开始运行了一个相对完整的C程序了。那是不是在这个基础上链接个C++程序就全剧终了呢?放心!自然不会。虽然说C++也可以很轻松链接到目前的工程上,但笔者希望能带领大家更近一步,比如进入64位模式,比如显示图像(而不是纯文本)。
本节的实例工程会上传至附件(12-2),后面章节我们还会继续探索。
小结
本篇将工程文件重新整理,并补充了一些C的库函数似的工程基本可用。下一篇将会介绍图形模式,以及在这个模式下的代码改造。