Introduction
回顾 Hello World
#include <stdio.h>
int main(){
printf("Hello, World\n");
return 0;
}
看似简单的 Hello World 其实一点也不简单。当使用 objdump -d a.out 查看 gcc 编译出来的结果时,会发现 a.out 中其实包含了很多东西。在程序执行时,是由谁调用的 main(),而 main() 在 return 时又将返回哪里?当没有 libc 时,我们应该如何让一个程序正常结束?
系统调用和最小 Hello World
用户程序只能通过系统调用 (以操作系统所允许的方式) 来向操作系统请求服务。为了在屏幕上打印 Hello World,我们仅需要请求两个服务:write() 和 exit()。
// hello.S
.section .data
msg: .asciz "Hello World\n" ;
.section .text
.globl _start
_start:
movq $1, %rax // write system call
movq $1, %rdi // file descriptor
movq $msg, %rsi // pointer to message
movq $12, %rdx // message length
syscall
movq $60, %rax // exit system call
movq $0, %rdi // return number
syscall
运行上述代码:
cpp hello.S > hello.i
as hello.i -o hello.o
ld hello.o
./a.out
系统调用追踪
我们可以通过 strace 来追踪一个应用程序在执行过程中产生的系统调用:
strace ./a.out
strace 是通过 ptrace() 这个系统调用来实现的,其可以在被追踪的程序每次进入或退出内核时停止其执行,然后追踪程序就可以进一步使用 ptrace() 来获取程序的状态。例如,下列代码给出了利用 ptrace() 实现的一个简易 tracer:
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
printf(stderr, "Usage: %s <command>\n", argv[0]);
exit(EXIT_FAILURE);
}
pid_t child = fork();
if (child == 0) {
// this process (tracee) is to be traced
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execvp(argv[1], &argv[1]);
} else {
int status;
struct user_regs_struct regs;
while (1) {
// wait for the tracee to stop itself
waitpid(child, &status, 0);
// if the child exits
if (WIFEXITED(status))
break;
// copy the tracee's general-purpose registers
ptrace(PTRACE_GETREGS, child, NULL, ®s);
printf("System call number: %lld\n", regs.orig_rax);
// restart the stopped tracee, and arrange for the tracee to be stopped
// at the next entry to or exit from a system call
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
}
}
return 0;
}
CPU Reset 之后的世界
为了让操作系统这个程序能够正确启动,计算机硬件和程序员之间首先约定好 CPU Reset 后的寄存器状态 (例如在 x86 family 中 EIP = 0xfff0),随后由固件 (Firmware) 来将程序员编写的代码载入内存并执行。例如,BIOS 会将第一个可引导设备的第一个 512 字节 (Master Boot Record, MBR) 加载到物理内存的 0x7c00 位置,并规定 CS:IP = 0x7c00。此时,虽然 MBR 只能容纳 400 多字节代码,但系统的控制权已回到程序员手中。
// mbr.S
#define SECT_SIZE 512
.code16 // 16-bit assembly
// Entry of the code
.globl _start
_start:
lea (msg), %si // R[si] = &msg;
again:
movb (%si), %al // R[al] = *R[si]; <--+
incw %si // R[si]++; |
orb %al, %al // if (!R[al]) |
jz done // goto done; --+ |
movb $0x0e, %ah // R[ah] = 0x0e; | |
movb $0x00, %bh // R[bh] = 0x00; | |
int $0x10 // bios_call(); | |
jmp again // goto again; ---+---+
done: // |
jmp . // goto done; <---+
// Data: const char msg[] = "...";
msg:
.asciz "This is a baby step towards operating systems!\r\n"
// Magic number for bootable device
.org SECT_SIZE - 2
.byte 0x55, 0xAA
# Makefile
mbr.img: mbr.S
gcc -ggdb -c $<
ld mbr.o -Ttext 0x7c00
objcopy -S -O binary -j .text a.out $@
run: mbr.img
qemu-system-x86_64 --nographic $<
# Run QEMU in background
# -s: open a gdb server on TCP port 1234
# -S: do not start CPU at startup
debug: mbr.img
qemu-system-x86_64 -s -S --nographic $< &
clean:
rm -f *.img *.o a.out
运行 make 来将上述 mbr.S 编译并转换为磁盘镜像文件 mbr.img,随后运行 make run 即可在 QEMU 中启动并执行 mbr.S 代码 (利用 bios_call 打印字符到屏幕)。当然,真实的 MBR 不是用来打印 Hello World 的,而是用于加载磁盘上的操作系统。
为了观察系统启动的过程,我们同样可以使用 gdb 来进行调试。运行 make debug 会在后台启动 QEMU,并在 localhost:1234 打开调试接口,随后运行 gbd 并通过 target remote localhost:1234 就可以连接到 QEMU 进行调试。