百科问答小站 logo
百科问答小站 font logo



C 如何编译出一个不需要操作系统的程序? 第1页

  

user avatar   skywind3000 网友的相关建议: 
      

来个更短的,没有其他乱七八糟的东西,只有一个简短的 C文件,不需要 linux 环境:

miniboot.c

       asm(".long 0x1badb002, 0, (-(0x1badb002 + 0))");  unsigned char *videobuf = (unsigned char*)0xb8000; const char *str = "Hello, World !! ";  int start_entry(void) {  int i;  for (i = 0; str[i]; i++) {   videobuf[i * 2 + 0] = str[i];   videobuf[i * 2 + 1] = 0x17;  }  for (; i < 80 * 25; i++) {   videobuf[i * 2 + 0] = ' ';   videobuf[i * 2 + 1] = 0x17;  }  while (1) { }  return 0; }      

编译:

       gcc -c -fno-builtin -ffreestanding -nostdlib -m32 miniboot.c -o miniboot.o ld -e start_entry -m elf_i386 -Ttext-seg=0x100000 miniboot.o -o miniboot.elf     

运行:

       qemu-system-i386 -kernel miniboot.elf     

结果:

满足条件:

  1. 只用纯 C 开发,可以使用 gcc 编译
  2. 编译出来的东西真的可以运行
  3. 不需要依赖操作系统
  4. 不需要包含系统调用的 glibc
  5. 连 libgcc 都不需要


解释一下:

Q:-m elf_i386 是什么鬼?为何使用 elf 格式?

是输出为 elf 的目标格式,386是目标平台,还可以输出成 binary 格式,就是没有任何额外信息,你代码里面写了什么就是什么。一般用在 boot loader stage1 的第一个 512字节扇区那里,就是用这种纯格式。

但是这里我们需要 elf 格式,因为 elf格式除了代码和数据外还有很多有用的信息,可以被标准 boot loader 识别,你如果不想自己花费10+天去自己实现 stage1 和 stage2 的 bootloader 的话,elf文件可以帮你省很多时间,因为 grub 可以直接加载,qemu 也可以直接运行。


Q:elf 包含了哪些内容?

代码段,数据段的运行位置(比如你有个全局变量,需要知道到哪个线性地址可以找到这个全局变量),各个段的线性地址从低到高的排列顺序,以及他们的加载地址,加载地址和运行地址可以不同,加载地址是告诉 boot loader 把这个段加载到哪里,比如加载地址可以是0x100000即1MB地址开始处,也是最常见做法,而运行地址可以是 0xc0000000 即最高端内存 1GB 的位置,还有程序入口的起始地址,最后还可以包含符号信息便于调试。这些丰富的信息可以告诉外层的 boot loader 如何加载我们的 elf 文件,到哪里去初始化我们的 bss 数据段,以及最终跳转到哪里去执行入口处的代码,这些内存布局信息需要再一个 .ld 文件里说明,这里我们不用 .ld 文件,全部使用默认配置,但后期你想详细指定这些的话,需要写 .ld 文件,链接的时候传递给 ld 程序。


Q:是不是所有 elf 文件都可以被 boot loader 加载?

不是,需要第一个段中(没有ld文件的话,第一个段默认就是 text)头部包含 multiboot header,就是:

       asm(".long 0x1badb002, 0, (-(0x1badb002 + 0))");      

一共定义了十二个字节的 multiboot header,第一个long是 magic code, grub/qemu等需要检查,第二个 long 代表你需要 grub 提供哪些信息(比如内存布局,elf结构),这里填写0,不需要它提供任何信息。第三个long是代表前两个运算以后的一个 checksum,grub/qemu 会检查这个值确认你真的是一个可以引导的 kernel。


Q:为什么加载到 0x100000 地址?

这是最常规的做法,因为如果低于 1MB的地址你可能会破坏到 BIOS 程序,显存,中断引射表,而且低端地址需要保留给 dma 使用。而如果加载到,比如 0x800000,也不是不可以,只是你需要保证目标电脑内存 > 8MB + 你的程序大小,所以 0x100000 这个地址是大家都可以接受的一个通用地址。


Q:我想再高端内存(0xc0000000)运行?

可以,这也是 linux 干的事情,这样可以给下面的应用程序留出足够的线性空间来,但是你需要初始化页表,把你自己映射到高端地址,并且需要再 .ld 文件里面指明加载地址(0x100000处)和运行地址(0xc0000000)是两个不同的地址,你还是会被加载到 0x100000 处,但是你的所有C代码都认为自己再 0xc0000000 处执行,所以此时还不能跑任何C代码,需要一段叫做 relocation 的汇编初始化页面映射,还有其他一些环境,并最终跳转到高端地址的 entry 入口。


Q:为什么不写 boot loader ?

给你节省时间,完善的boot loader涉及到大量的技巧,用现成的boot loader,可以让你马上看到结果,有正反馈。


Q:如果我执意要写个完善的 boot loader 呢?

首先要写引导扇区代码,调用 BIOS 磁盘中断,把你后面的 elf 文件加载进来,但是这时boot loader还处在实模式,只能访问 1MB以内的空间,也就是说你的 elf 文件大小受到了限制,要加载到 1MB以上的空间,需要 boot loader 切换到保护模式,但是此时又不能访问传统的 BIOS 中断请求磁盘了,所有你需要来回切换保护模式和实模式,或者用 v86 模式,或者写保护模式下的硬盘驱动,显然这不是 512字节能够完成的事情,所以你还需要 stage 2 的 boot loader。

传统 512 字节的 boot loader 先在实模式下把 stage 2 加载到 0x10000 处(64KB处),然后又由 stage2 的程序进入保护模式,并用 v86 模式调用 BIOS 的磁盘中断服务程序,把你的 elf 文件找出来,并且解析 elf 结构,把各个段加载到它期望的位置,最后初始化一个临时的 GDT 和栈,把 bss 段全部置0,关闭中断标志,最后再跳转到 elf 的 entry 处,完成引导。

注意:stage1 的 512字节,和 stage2的后续扇区代码,目标格式都是纯二进制,不是 elf。

注意:stage2 的代码里需要找出你的 elf,这时你的 elf 可以简单的写到一个裸分区上,这样找起来容易点,如果你需要把 elf 放到 fat16/fat32 分区的某个目录中,那么 stage2 代码就要写相关 fat 文件系统的识别代码(只需要简单的文件/目录读取,不需要写,所以不需要实现整个文件系统),而如果你想把 elf 文件放到除 fat 外的其他文件系统需要支持多文件系统的话,stage2就搞不定了,需要 stage3 可以动态加载不同文件系统的模块化程序,做更复杂的动作。


Q:这个 miniboot 程序可以接着自由往下写么?

很遗憾,可能你只能写点很短的代码,因为:

  1. 你还没有relocate,一直在1MB处跑你的代码不是件好事,尽早重定位到最高1GB处。
  2. 你还没有初始化栈,你不知道 grub 到底给你的栈指针 esp 设定到哪里了,你需要自己规划栈空间。
  3. 你没有 libc 库,什么 memcpy, strlen , printf 都没有,你需要一个个实现。
  4. 你没有 libgcc 库(是gcc的一部分),可能你做64为乘除法的代码无法得到链接,或者你在类似 arm 的平台下连 32为的除法都没法做,在 risc v 的标准平台下,连普通乘法都没有,一旦写了链接就不通过,因为没有 libgcc,你可以软件模拟(控制狂的话),或者选择链接 libgcc。
  5. 硬件你还没探测,最基本的,你都还不知道你的内存多大,怎么布局的(根据接口不同,内存各个模块的物理地址不一定连续)。
  6. 你没法接受键盘输入,需要初始化键盘中断来接管键盘。
  7. 你没有办法读取磁盘,因为你现在是保护模式,BIOS 下面的磁盘访问程序都是实模式的,幸好你加载到1MB地区没覆盖他们,这时候你可以用v86模式去调用这些中断,或者自己写硬盘驱动。
  8. 你还没有相关调试手段,往下写出点错误可能都会折腾死你,backtrace 要有吧,panic要有吧,最好能搜索符号表把函数名都打印出来,最好初始化串口,用com1 和外部通信,这样比把信息输出到屏幕靠谱,屏幕大小有限,信息稍纵即逝,com1的话可以和外部串口中端进行通信,qemu/bochs还可以把com1的内容记录到日志文件,永久保存。
  9. 你还没法分配内存,你需要把前面内存探测结果里的可用物理内存页面统计起来,先实现基于页面的分配和映射,再以前面为基础实现基于对象的分配,然后你才有 malloc / free。

先把这九个问题解决,你就可以继续往下写更多复杂的内容了。


先写这么多吧。


user avatar   feng-dong 网友的相关建议: 
      

现代 OS kernel 是没有 loader 的。就是说先需要一个 loader 把 OS kernel image 调入到预先约定的内存区域,然后再调用 kernel 的 entry function。这样 kernel 就运行起来了。对于这样的 kernel,针对题主的问题,从依赖性来考虑,DOS 时代的任何一个 app 都是这样的。因为 DOS 本质来说只是一个 loader。(当然 DOS 还构建了一个 Int 21,不过从依赖角度说这个 Int 21 和一个普通函数差不多。)

其实当时以 DOS 为 loader 的真正的操作系统也不少,Windows 1.x - 3.x,还有 DOS/4G 都是如此。

至于 kernel 做到独立,你的源代码里不要调用诸如 system call 或者 C runtime 的函数就可以了。如果需要类似的功能,你要自己写代码向特定的 I/O port 或者 I/O mapping address 读写数据来完成。或者你和硬件开发者商量好一个 device driver 接口,让他们写底层的细节。

至于 loader 本身也没什么神奇的,因为 CPU 里有一个「硬 loader entry」,一加电就自动运行。所以你就把你的 loader 安装到这个「硬 loader entry」指定的区域就好。普通 PC 的硬 loader 是 BIOS,现在是 UEFI。这个 BIOS/UEFI 又规定了从磁盘的什么地方再调一个 loader。

现代编译器一般有两个功能:一是指定一个特定函数在生成后的文件里的偏移量,二是在生成后的文件里保存一张 symbol table,说明每个函数的偏移量。对于要求比较死的 loader,就把 entry function 设置成规定的偏移量就行。对于高级 loader,基本上不用 compiler 做特定工作,直接把 loader 配置好就行了。

如果编译器功能实在达不到,loader 又比较傻,那 kernel 开发者就要写自己的工具了。比如写个程序,生成一段汇编,放到整个文件最前面(如果 loader 规定如此),让控制流 jmp 跳到真正的 entry fucntion 位置(这个位置也可以在 build kernel 的时候用自制或者通用工具得到)。




  

相关话题

  如果把内存条掰一半再粘上,还能使用吗? 
  cpu对于内存的读写会受制于内存延迟,处理逻辑更类似于web的阻塞模型还是异步模型? 
  为什么SSD能够使成熟的操作系统的体验获得如此多的提升? 
  选北大还是选港大? 
  突然产生一个想法,本人是学计算机的一个妹子,录视频会有人看吗? 
  怎么翻译 side effects 好? 
  如何看待南开大学一同学一边走路一边开着电脑写代码?当代大学生学习现状是怎样的呢? 
  未来会不会出现这样的编程语言? 
  计算机专业大学生想要在以后有一份好工作,在大学期间应该及时考哪些证书? 
  ECC内存和普通内存差别有多大? 

前一个讨论
为什么人类想不出四维空间?
下一个讨论
为什么有些阿富汗孩子被举过墙给美国士兵?这是父母最好的选择了吗?





© 2024-05-08 - tinynew.org. All Rights Reserved.
© 2024-05-08 - tinynew.org. 保留所有权利