使用外部 C 代码编译 asm 引导加载程序
Compile an asm bootloader with external c code
我用 asm 编写了一个引导加载程序,想在我的项目中添加一些编译后的 C 代码。
我在这里创建了一个测试函数:
test.c
__asm__(".code16\n");
void print_str() {
__asm__ __volatile__("mov $'A' , %al\n");
__asm__ __volatile__("mov [=10=]x0e, %ah\n");
__asm__ __volatile__("int [=10=]x10\n");
}
这里是 asm 代码(引导加载程序):
hw.asm
[org 0x7C00]
[BITS 16]
[extern print_str] ;nasm tip
start:
mov ax, 0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
mov si, name
call print_string
mov al, ' '
int 10h
mov si, version
call print_string
mov si, line_return
call print_string
call print_str ;call function
mov si, welcome
call print_string
jmp mainloop
mainloop:
mov si, prompt
call print_string
mov di, buffer
call get_str
mov si, buffer
cmp byte [si], 0
je mainloop
mov si, buffer
;call print_string
mov di, cmd_version
call strcmp
jc .version
jmp mainloop
.version:
mov si, name
call print_string
mov al, ' '
int 10h
mov si, version
call print_string
mov si, line_return
call print_string
jmp mainloop
name db 'MOS', 0
version db 'v0.1', 0
welcome db 'Developped by Marius Van Nieuwenhuyse', 0x0D, 0x0A, 0
prompt db '>', 0
line_return db 0x0D, 0x0A, 0
buffer times 64 db 0
cmd_version db 'version', 0
%include "functions/print.asm"
%include "functions/getstr.asm"
%include "functions/strcmp.asm"
times 510 - ($-$$) db 0
dw 0xaa55
我需要像调用简单的 asm 函数一样调用 c 函数
没有 extern 和调用 print_str
,VMWare 中的 asm 脚本启动。
我尝试编译:
nasm -f elf32
但是我不能调用 org 0x7C00
I write a boot loader in asm and want to add some compiled C code in my project.
那么你需要使用16位的x86编译器,比如OpenWatcom.
GCC 无法安全地构建实模式代码,因为它不知道该平台的一些重要特性,包括内存分段。插入 .code16
指令将使编译器生成不正确的输出。尽管出现在许多教程中,这条建议是完全错误的,不应使用。
首先我想表达如何link C 编译代码与assembled 文件。
我在 SO 中整理了一些 Q/A 并达到了这个目的。
C代码:
func.c
//__asm__(".code16gcc\n");when we use eax, 32 bit reg we cant use this as truncate
//problem
#include <stdio.h>
int x = 0;
int madd(int a, int b)
{
return a + b;
}
void mexit(){
__asm__ __volatile__("mov [=10=], %ebx\n");
__asm__ __volatile__("mov , %eax \n");
__asm__ __volatile__("int [=10=]x80\n");
}
char* tmp;
///how to direct use of arguments in asm command
void print_str(int a, char* s){
x = a;
__asm__("mov x, %edx\n");// ;third argument: message length
tmp = s;
__asm__("mov tmp, %ecx\n");// ;second argument: pointer to message to write
__asm__("mov , %ebx\n");//first argument: file handle (stdout)
__asm__("mov , %eax\n");//system call number (sys_write)
__asm__ __volatile__("int [=10=]x80\n");//call kernel
}
void mtest(){
printf("%s\n", "Hi");
//putchar('a');//why not work
}
///gcc -c func.c -o func
汇编代码:
hello.asm
extern mtest
extern printf
extern putchar
extern print_str
extern mexit
extern madd
section .text ;section declaration
;we must export the entry point to the ELF linker or
global _start ;loader. They conventionally recognize _start as their
;entry point. Use ld -e foo to override the default.
_start:
;write our string to stdout
push msg
push len
call print_str;
call mtest ;print "Hi"; call printf inside a void function
; use add inside func.c
push 5
push 10
call madd;
;direct call of <stdio.h> printf()
push eax
push format
call printf; ;printf(format, eax)
call mexit; ;exit to OS
section .data ;section declaration
format db "%d", 10, 0
msg db "Hello, world!",0xa ;our dear string
len equ $ - msg ;length of our dear string
; nasm -f elf32 hello.asm -o hello
;Link two files
;ld hello func -o hl -lc -I /lib/ld-linux.so.2
; ./hl run code
;chain to assemble, compile, Run
;; gcc -c func.c -o func && nasm -f elf32 hello.asm -o hello && ld hello func -o hl -lc -I /lib/ld-linux.so.2 && echo &&./hl
assemble、编译和运行
的链式命令
gcc -c func.c -o func && nasm -f elf32 hello.asm -o hello && ld hello func -o hl -lc -I /lib/ld-linux.so.2 && echo && ./hl
编辑[toDO]
- 编写引导加载程序代码而不是这个版本
- 关于 ld、gcc、nasm 工作原理的一些解释。
编译和链接 NASM 和 GCC 代码
这个问题的答案比人们想象的要复杂,尽管这是可能的。引导加载程序的第一阶段(在物理地址 0x07c00 加载的原始 512 字节)能否调用 C 函数?是的,但它需要重新考虑如何构建项目。
要实现此功能,您不能再使用 -f bin
和 NASM。这也意味着您不能使用 org 0x7c00
来告诉汇编器代码期望从哪个地址开始。您需要通过 linker 完成此操作(我们直接 LD 或 GCC for linking) .由于 linker 会将内容放在内存中,因此我们不能依赖于将引导扇区签名 0xaa55
放在输出文件中。我们可以让 linker 为我们做这件事。
您会发现的第一个问题是 GCC 内部使用的默认 linker 脚本不会按照我们想要的方式进行布局。我们需要创建自己的。这样的 linker 脚本必须将原点(虚拟内存地址又名 VMA)设置为 0x7c00,将汇编文件中的代码放在数据之前,并将引导签名放在文件中的偏移量 510 处。我不打算编写有关链接器脚本的教程。 Binutils Documentation 包含您需要了解的关于 linker 脚本的几乎所有内容。
OUTPUT_FORMAT("elf32-i386");
/* We define an entry point to keep the linker quiet. This entry point
* has no meaning with a bootloader in the binary image we will eventually
* generate. Bootloader will start executing at whatever is at 0x07c00 */
ENTRY(start);
SECTIONS
{
. = 0x7C00;
.text : {
/* Place the code in hw.o before all other code */
hw.o(.text);
*(.text);
}
/* Place the data after the code */
.data : SUBALIGN(2) {
*(.data);
*(.rodata*);
}
/* Place the boot signature at LMA/VMA 0x7DFE */
.sig 0x7DFE : {
SHORT(0xaa55);
}
/* Place the uninitialised data in the area after our bootloader
* The BIOS only reads the 512 bytes before this into memory */
.bss : SUBALIGN(4) {
__bss_start = .;
*(COMMON);
*(.bss)
. = ALIGN(4);
__bss_end = .;
}
__bss_sizeb = SIZEOF(.bss);
/* Remove sections that won't be relevant to us */
/DISCARD/ : {
*(.eh_frame);
*(.comment);
}
}
此脚本应创建一个 ELF 可执行文件,可以使用 OBJCOPY 将其转换为平面二进制文件。我们可以直接将输出作为二进制文件,但如果我想在 ELF 版本中包含调试信息以用于调试目的,我将两个进程分开。
现在我们有了一个 linker 脚本,我们必须删除 ORG 0x7c00
和启动签名。为了简单起见,我们将尝试让以下代码 (hw.asm
) 工作:
extern print_str
global start
bits 16
section .text
start:
xor ax, ax ; AX = 0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
call print_str ; call function
/* Halt the processor so we don't keep executing code beyond this point */
cli
hlt
您可以包含所有其他代码,但此示例仍将演示调用 C 函数的基础知识。
假设上面的代码您现在可以使用以下命令从 hw.asm
生成 hw.o
生成 ELF 对象:
nasm -f elf32 hw.asm -o hw.o
你编译每个 C 文件,像这样:
gcc -ffreestanding -c kmain.c -o kmain.o
我将您拥有的 C 代码放入了一个名为 kmain.c
的文件中。上面的命令将生成 kmain.o
。我注意到您没有使用交叉编译器,因此您需要使用 -fno-PIE
来确保我们不会生成可重定位代码。 -ffreestanding
告诉 GCC C 标准库可能不存在,并且 main
可能不是程序入口点。您将以相同的方式编译每个 C 文件。
要link将此代码生成最终的可执行文件,然后生成一个可以启动的平面二进制文件,我们这样做:
ld -melf_i386 --build-id=none -T link.ld kmain.o hw.o -o kernel.elf
objcopy -O binary kernel.elf kernel.bin
您使用 LD 命令将所有目标文件指定为 link。上面的 LD 命令将生成一个名为 kernel.elf
的 32 位 ELF 可执行文件。此文件将来可用于调试目的。这里我们使用 OBJCOPY 将 kernel.elf
转换为名为 kernel.bin
的二进制文件。 kernel.bin
可用作引导加载程序映像。
你应该能够 运行 它与 QEMU 使用这个命令:
qemu-system-i386 -fda kernel.bin
当 运行 它可能看起来像:
您会注意到字母 A
出现在最后一行。这就是我们对 print_str
代码的期望。
GCC 内联汇编很难做到正确
如果我们在问题中采用您的示例代码:
__asm__ __volatile__("mov $'A' , %al\n");
__asm__ __volatile__("mov [=16=]x0e, %ah\n");
__asm__ __volatile__("int [=16=]x10\n");
如果需要,编译器可以自由地重新排序这些 __asm__
语句。 int [=48=]x10
可以出现在 MOV 指令之前。如果你希望这 3 行以这个确切的顺序输出,你可以像这样将它们组合成一个:
__asm__ __volatile__("mov $'A' , %al\n\t"
"mov [=17=]x0e, %ah\n\t"
"int [=17=]x10");
这些是基本的汇编语句。不需要在它们上指定 __volatile__
,因为它们已经是 implicitly volatile, so it has no effect. From the original poster's answer it is clear they want to eventually use variables in __asm__
blocks. This is doable with extended inline assembly(指令字符串后跟一个冒号 :
,然后是约束。):
使用扩展 asm,您可以从汇编程序读取和写入 C 变量,并执行从汇编程序代码到 C 标签的跳转。扩展 asm 语法使用冒号(‘:’)分隔汇编器模板后的操作数参数:
asm [volatile] ( AssemblerTemplate
: OutputOperands
[ : InputOperands
[ : Clobbers ] ])
这个答案不是关于内联汇编的教程。一般的经验法则是一个 should not use inline assembly unless you have to。错误的内联汇编会产生难以跟踪的错误或产生不寻常的副作用。不幸的是,在 C 中执行 16 位中断非常需要它,或者您在汇编中编写整个函数(即:NASM)。
这是一个 print_chr
函数的例子,它接受一个以 nul 结尾的字符串,并使用 Int 10h/ah=0ah:
一个一个地打印出每个字符
#include <stdint.h>
__asm__(".code16gcc\n");
void print_str(char *str) {
while (*str) {
/* AH=0x0e, AL=char to print, BH=page, BL=fg color */
__asm__ __volatile__ ("int [=19=]x10"
:
: "a" ((0x0e<<8) | *str++),
"b" (0x0000));
}
}
hw.asm
将被修改为如下所示:
push welcome
call print_str ;call function
这是 assembled/compiled(使用本答案第一部分中的命令)和 运行 时的想法是打印出 welcome
消息。不幸的是,它几乎永远不会工作,甚至可能使一些模拟器崩溃,比如 QEMU.
code16 几乎没用,不应该使用
在上一节中,我们了解到一个带参数的简单函数最终无法正常工作,甚至可能使 QEMU 等模拟器崩溃。主要问题是 __asm__(".code16\n");
语句确实不能很好地与 GCC 生成的代码一起工作。 Binutils AS documentation 表示:
‘.code16gcc’ provides experimental support for generating 16-bit code from gcc, and differs from ‘.code16’ in that ‘call’, ‘ret’, ‘enter’, ‘leave’, ‘push’, ‘pop’, ‘pusha’, ‘popa’, ‘pushf’, and ‘popf’ instructions default to 32-bit size. This is so that the stack pointer is manipulated in the same way over function calls, allowing access to function parameters at the same stack offsets as in 32-bit mode. ‘.code16gcc’ also automatically adds address size prefixes where necessary to use the 32-bit addressing modes that gcc generates.
.code16gcc
是您真正需要使用的,而不是 .code16
。这会强制后端的 GNU 汇编程序在某些指令上发出地址和操作数前缀,以便地址和操作数被视为 4 字节宽,而不是 2 字节。
NASM中的手写代码不知道会调用C指令,NASM 有一个像 .code16gcc
这样的指令。您需要修改汇编代码以在实模式下将 32 位值压入堆栈。您还需要覆盖 call
指令,以便 return 地址需要被视为 32 位值,而不是 16 位值。此代码:
push welcome
call print_str ;call function
应该是:
jmp 0x0000:setcs
setcs:
cld
push dword welcome
call dword print_str ;call function
GCC 要求在调用任何 C 函数之前清除方向标志。我在汇编代码的顶部添加了 CLD 指令以确保是这种情况。 GCC 代码也需要 CS 到 0x0000 才能正常工作。 FAR JMP 就是这样做的。
您还可以在支持 -m16
选项的现代 GCC 上删除 __asm__(".code16gcc\n");
。 -m16
自动将 .code16gcc
放入正在编译的文件中。
因为 GCC 也使用完整的 32 位堆栈指针,所以用 0x7c00 初始化 ESP 是个好主意,而不仅仅是 SP。将 mov sp, 0x7C00
更改为 mov esp, 0x7C00
。这确保完整的 32 位堆栈指针为 0x7c00。
修改后的 kmain.c
代码现在应该如下所示:
#include <stdint.h>
void print_str(char *str) {
while (*str) {
/* AH=0x0e, AL=char to print, BH=page, BL=fg color */
__asm__ __volatile__ ("int [=23=]x10"
:
: "a" ((0x0e<<8) | *str++),
"b" (0x0000));
}
}
和hw.asm
:
extern print_str
global start
bits 16
section .text
start:
xor ax, ax ; AX = 0
mov ds, ax
mov es, ax
mov ss, ax
mov esp, 0x7C00
jmp 0x0000:setcs ; Set CS to 0
setcs:
cld ; GCC code requires direction flag to be cleared
push dword welcome
call dword print_str ; call function
cli
hlt
section .data
welcome db 'Developped by Marius Van Nieuwenhuyse', 0x0D, 0x0A, 0
可以使用这些命令构建引导加载程序:
gcc -fno-PIC -ffreestanding -m16 -c kmain.c -o kmain.o
ld -melf_i386 --build-id=none -T link.ld kmain.o hw.o -o kernel.elf
objcopy -O binary kernel.elf kernel.bin
当 运行 和 qemu-system-i386 -fda kernel.bin
看起来应该类似于:
在大多数情况下,GCC 生成的代码需要 80386+
GCC 使用 .code16gcc
生成的代码有很多缺点:
- ES=DS=CS=SS 必须为 0
- 代码必须适合前 64kb
- GCC 代码不理解 20 位 segment:offset 寻址。
- 除了最琐碎的 C 代码之外,GCC 不会生成可以在 286/186/8086 上 运行 的代码。它 运行 处于实模式,但它使用 32 位操作数和寻址,在早于 80386 的处理器上不可用。
- 如果你想访问第一个 64kb 以上的内存位置,那么你需要在调用 C 代码之前进入 Unreal Mode(big)。
如果您想从更现代的 C 编译器生成真正的 16 位代码,我推荐 OpenWatcom C
- 内联汇编不如GCC
强大
- 内联汇编语法不同,但它比 GCC 的内联汇编更易于使用且不易出错。
- 可以生成可在过时的 8086/8088 处理器上 运行 的代码。
- 理解 20 位 segment:offset 实模式寻址并支持远指针和大指针的概念。
wlink
Watcom linker 可以生成可用作引导加载程序的基本平面二进制文件。
零填充 BSS 部分
BIOS 启动顺序不能保证内存实际上为零。这会导致零初始化区域 BSS 出现潜在问题。在第一次调用 C 代码之前,该区域应该由我们的汇编代码填充为零。我最初编写的 linker 脚本定义了一个符号 __bss_start
,它是 BSS 内存的偏移量,而 __bss_sizeb
是以字节为单位的大小。使用此信息,您可以使用 STOSB 指令轻松将其填零。在 hw.asm
的顶部您可以添加:
extern __bss_sizeb
extern __bss_start
并且在 CLD 指令之后和调用任何 C 代码之前,您可以通过这种方式进行零填充:
; Zero fill the BSS section
mov cx, __bss_sizeb ; Size of BSS computed in linker script
mov di, __bss_start ; Start of BSS defined in linker script
rep stosb ; AL still zero, Fill memory with zero
其他建议
为了减少编译器生成的代码的膨胀,使用 -fomit-frame-pointer
会很有用。使用 -Os
编译可以优化 space(而不是速度)。我们限制了 BIOS 加载的初始代码的 space(512 字节),因此这些优化可能是有益的。编译命令行可能显示为:
gcc -fno-PIC -fomit-frame-pointer -ffreestanding -m16 -Os -c kmain.c -o kmain.o
我用 asm 编写了一个引导加载程序,想在我的项目中添加一些编译后的 C 代码。
我在这里创建了一个测试函数:
test.c
__asm__(".code16\n");
void print_str() {
__asm__ __volatile__("mov $'A' , %al\n");
__asm__ __volatile__("mov [=10=]x0e, %ah\n");
__asm__ __volatile__("int [=10=]x10\n");
}
这里是 asm 代码(引导加载程序):
hw.asm
[org 0x7C00]
[BITS 16]
[extern print_str] ;nasm tip
start:
mov ax, 0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
mov si, name
call print_string
mov al, ' '
int 10h
mov si, version
call print_string
mov si, line_return
call print_string
call print_str ;call function
mov si, welcome
call print_string
jmp mainloop
mainloop:
mov si, prompt
call print_string
mov di, buffer
call get_str
mov si, buffer
cmp byte [si], 0
je mainloop
mov si, buffer
;call print_string
mov di, cmd_version
call strcmp
jc .version
jmp mainloop
.version:
mov si, name
call print_string
mov al, ' '
int 10h
mov si, version
call print_string
mov si, line_return
call print_string
jmp mainloop
name db 'MOS', 0
version db 'v0.1', 0
welcome db 'Developped by Marius Van Nieuwenhuyse', 0x0D, 0x0A, 0
prompt db '>', 0
line_return db 0x0D, 0x0A, 0
buffer times 64 db 0
cmd_version db 'version', 0
%include "functions/print.asm"
%include "functions/getstr.asm"
%include "functions/strcmp.asm"
times 510 - ($-$$) db 0
dw 0xaa55
我需要像调用简单的 asm 函数一样调用 c 函数
没有 extern 和调用 print_str
,VMWare 中的 asm 脚本启动。
我尝试编译:
nasm -f elf32
但是我不能调用 org 0x7C00
I write a boot loader in asm and want to add some compiled C code in my project.
那么你需要使用16位的x86编译器,比如OpenWatcom.
GCC 无法安全地构建实模式代码,因为它不知道该平台的一些重要特性,包括内存分段。插入 .code16
指令将使编译器生成不正确的输出。尽管出现在许多教程中,这条建议是完全错误的,不应使用。
首先我想表达如何link C 编译代码与assembled 文件。
我在 SO 中整理了一些 Q/A 并达到了这个目的。
C代码:
func.c
//__asm__(".code16gcc\n");when we use eax, 32 bit reg we cant use this as truncate
//problem
#include <stdio.h>
int x = 0;
int madd(int a, int b)
{
return a + b;
}
void mexit(){
__asm__ __volatile__("mov [=10=], %ebx\n");
__asm__ __volatile__("mov , %eax \n");
__asm__ __volatile__("int [=10=]x80\n");
}
char* tmp;
///how to direct use of arguments in asm command
void print_str(int a, char* s){
x = a;
__asm__("mov x, %edx\n");// ;third argument: message length
tmp = s;
__asm__("mov tmp, %ecx\n");// ;second argument: pointer to message to write
__asm__("mov , %ebx\n");//first argument: file handle (stdout)
__asm__("mov , %eax\n");//system call number (sys_write)
__asm__ __volatile__("int [=10=]x80\n");//call kernel
}
void mtest(){
printf("%s\n", "Hi");
//putchar('a');//why not work
}
///gcc -c func.c -o func
汇编代码:
hello.asm
extern mtest
extern printf
extern putchar
extern print_str
extern mexit
extern madd
section .text ;section declaration
;we must export the entry point to the ELF linker or
global _start ;loader. They conventionally recognize _start as their
;entry point. Use ld -e foo to override the default.
_start:
;write our string to stdout
push msg
push len
call print_str;
call mtest ;print "Hi"; call printf inside a void function
; use add inside func.c
push 5
push 10
call madd;
;direct call of <stdio.h> printf()
push eax
push format
call printf; ;printf(format, eax)
call mexit; ;exit to OS
section .data ;section declaration
format db "%d", 10, 0
msg db "Hello, world!",0xa ;our dear string
len equ $ - msg ;length of our dear string
; nasm -f elf32 hello.asm -o hello
;Link two files
;ld hello func -o hl -lc -I /lib/ld-linux.so.2
; ./hl run code
;chain to assemble, compile, Run
;; gcc -c func.c -o func && nasm -f elf32 hello.asm -o hello && ld hello func -o hl -lc -I /lib/ld-linux.so.2 && echo &&./hl
assemble、编译和运行
的链式命令
gcc -c func.c -o func && nasm -f elf32 hello.asm -o hello && ld hello func -o hl -lc -I /lib/ld-linux.so.2 && echo && ./hl
编辑[toDO]
- 编写引导加载程序代码而不是这个版本
- 关于 ld、gcc、nasm 工作原理的一些解释。
编译和链接 NASM 和 GCC 代码
这个问题的答案比人们想象的要复杂,尽管这是可能的。引导加载程序的第一阶段(在物理地址 0x07c00 加载的原始 512 字节)能否调用 C 函数?是的,但它需要重新考虑如何构建项目。
要实现此功能,您不能再使用 -f bin
和 NASM。这也意味着您不能使用 org 0x7c00
来告诉汇编器代码期望从哪个地址开始。您需要通过 linker 完成此操作(我们直接 LD 或 GCC for linking) .由于 linker 会将内容放在内存中,因此我们不能依赖于将引导扇区签名 0xaa55
放在输出文件中。我们可以让 linker 为我们做这件事。
您会发现的第一个问题是 GCC 内部使用的默认 linker 脚本不会按照我们想要的方式进行布局。我们需要创建自己的。这样的 linker 脚本必须将原点(虚拟内存地址又名 VMA)设置为 0x7c00,将汇编文件中的代码放在数据之前,并将引导签名放在文件中的偏移量 510 处。我不打算编写有关链接器脚本的教程。 Binutils Documentation 包含您需要了解的关于 linker 脚本的几乎所有内容。
OUTPUT_FORMAT("elf32-i386");
/* We define an entry point to keep the linker quiet. This entry point
* has no meaning with a bootloader in the binary image we will eventually
* generate. Bootloader will start executing at whatever is at 0x07c00 */
ENTRY(start);
SECTIONS
{
. = 0x7C00;
.text : {
/* Place the code in hw.o before all other code */
hw.o(.text);
*(.text);
}
/* Place the data after the code */
.data : SUBALIGN(2) {
*(.data);
*(.rodata*);
}
/* Place the boot signature at LMA/VMA 0x7DFE */
.sig 0x7DFE : {
SHORT(0xaa55);
}
/* Place the uninitialised data in the area after our bootloader
* The BIOS only reads the 512 bytes before this into memory */
.bss : SUBALIGN(4) {
__bss_start = .;
*(COMMON);
*(.bss)
. = ALIGN(4);
__bss_end = .;
}
__bss_sizeb = SIZEOF(.bss);
/* Remove sections that won't be relevant to us */
/DISCARD/ : {
*(.eh_frame);
*(.comment);
}
}
此脚本应创建一个 ELF 可执行文件,可以使用 OBJCOPY 将其转换为平面二进制文件。我们可以直接将输出作为二进制文件,但如果我想在 ELF 版本中包含调试信息以用于调试目的,我将两个进程分开。
现在我们有了一个 linker 脚本,我们必须删除 ORG 0x7c00
和启动签名。为了简单起见,我们将尝试让以下代码 (hw.asm
) 工作:
extern print_str
global start
bits 16
section .text
start:
xor ax, ax ; AX = 0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
call print_str ; call function
/* Halt the processor so we don't keep executing code beyond this point */
cli
hlt
您可以包含所有其他代码,但此示例仍将演示调用 C 函数的基础知识。
假设上面的代码您现在可以使用以下命令从 hw.asm
生成 hw.o
生成 ELF 对象:
nasm -f elf32 hw.asm -o hw.o
你编译每个 C 文件,像这样:
gcc -ffreestanding -c kmain.c -o kmain.o
我将您拥有的 C 代码放入了一个名为 kmain.c
的文件中。上面的命令将生成 kmain.o
。我注意到您没有使用交叉编译器,因此您需要使用 -fno-PIE
来确保我们不会生成可重定位代码。 -ffreestanding
告诉 GCC C 标准库可能不存在,并且 main
可能不是程序入口点。您将以相同的方式编译每个 C 文件。
要link将此代码生成最终的可执行文件,然后生成一个可以启动的平面二进制文件,我们这样做:
ld -melf_i386 --build-id=none -T link.ld kmain.o hw.o -o kernel.elf
objcopy -O binary kernel.elf kernel.bin
您使用 LD 命令将所有目标文件指定为 link。上面的 LD 命令将生成一个名为 kernel.elf
的 32 位 ELF 可执行文件。此文件将来可用于调试目的。这里我们使用 OBJCOPY 将 kernel.elf
转换为名为 kernel.bin
的二进制文件。 kernel.bin
可用作引导加载程序映像。
你应该能够 运行 它与 QEMU 使用这个命令:
qemu-system-i386 -fda kernel.bin
当 运行 它可能看起来像:
您会注意到字母 A
出现在最后一行。这就是我们对 print_str
代码的期望。
GCC 内联汇编很难做到正确
如果我们在问题中采用您的示例代码:
__asm__ __volatile__("mov $'A' , %al\n");
__asm__ __volatile__("mov [=16=]x0e, %ah\n");
__asm__ __volatile__("int [=16=]x10\n");
如果需要,编译器可以自由地重新排序这些 __asm__
语句。 int [=48=]x10
可以出现在 MOV 指令之前。如果你希望这 3 行以这个确切的顺序输出,你可以像这样将它们组合成一个:
__asm__ __volatile__("mov $'A' , %al\n\t"
"mov [=17=]x0e, %ah\n\t"
"int [=17=]x10");
这些是基本的汇编语句。不需要在它们上指定 __volatile__
,因为它们已经是 implicitly volatile, so it has no effect. From the original poster's answer it is clear they want to eventually use variables in __asm__
blocks. This is doable with extended inline assembly(指令字符串后跟一个冒号 :
,然后是约束。):
使用扩展 asm,您可以从汇编程序读取和写入 C 变量,并执行从汇编程序代码到 C 标签的跳转。扩展 asm 语法使用冒号(‘:’)分隔汇编器模板后的操作数参数:
asm [volatile] ( AssemblerTemplate : OutputOperands [ : InputOperands [ : Clobbers ] ])
这个答案不是关于内联汇编的教程。一般的经验法则是一个 should not use inline assembly unless you have to。错误的内联汇编会产生难以跟踪的错误或产生不寻常的副作用。不幸的是,在 C 中执行 16 位中断非常需要它,或者您在汇编中编写整个函数(即:NASM)。
这是一个 print_chr
函数的例子,它接受一个以 nul 结尾的字符串,并使用 Int 10h/ah=0ah:
#include <stdint.h>
__asm__(".code16gcc\n");
void print_str(char *str) {
while (*str) {
/* AH=0x0e, AL=char to print, BH=page, BL=fg color */
__asm__ __volatile__ ("int [=19=]x10"
:
: "a" ((0x0e<<8) | *str++),
"b" (0x0000));
}
}
hw.asm
将被修改为如下所示:
push welcome
call print_str ;call function
这是 assembled/compiled(使用本答案第一部分中的命令)和 运行 时的想法是打印出 welcome
消息。不幸的是,它几乎永远不会工作,甚至可能使一些模拟器崩溃,比如 QEMU.
code16 几乎没用,不应该使用
在上一节中,我们了解到一个带参数的简单函数最终无法正常工作,甚至可能使 QEMU 等模拟器崩溃。主要问题是 __asm__(".code16\n");
语句确实不能很好地与 GCC 生成的代码一起工作。 Binutils AS documentation 表示:
‘.code16gcc’ provides experimental support for generating 16-bit code from gcc, and differs from ‘.code16’ in that ‘call’, ‘ret’, ‘enter’, ‘leave’, ‘push’, ‘pop’, ‘pusha’, ‘popa’, ‘pushf’, and ‘popf’ instructions default to 32-bit size. This is so that the stack pointer is manipulated in the same way over function calls, allowing access to function parameters at the same stack offsets as in 32-bit mode. ‘.code16gcc’ also automatically adds address size prefixes where necessary to use the 32-bit addressing modes that gcc generates.
.code16gcc
是您真正需要使用的,而不是 .code16
。这会强制后端的 GNU 汇编程序在某些指令上发出地址和操作数前缀,以便地址和操作数被视为 4 字节宽,而不是 2 字节。
NASM中的手写代码不知道会调用C指令,NASM 有一个像 .code16gcc
这样的指令。您需要修改汇编代码以在实模式下将 32 位值压入堆栈。您还需要覆盖 call
指令,以便 return 地址需要被视为 32 位值,而不是 16 位值。此代码:
push welcome
call print_str ;call function
应该是:
jmp 0x0000:setcs
setcs:
cld
push dword welcome
call dword print_str ;call function
GCC 要求在调用任何 C 函数之前清除方向标志。我在汇编代码的顶部添加了 CLD 指令以确保是这种情况。 GCC 代码也需要 CS 到 0x0000 才能正常工作。 FAR JMP 就是这样做的。
您还可以在支持 -m16
选项的现代 GCC 上删除 __asm__(".code16gcc\n");
。 -m16
自动将 .code16gcc
放入正在编译的文件中。
因为 GCC 也使用完整的 32 位堆栈指针,所以用 0x7c00 初始化 ESP 是个好主意,而不仅仅是 SP。将 mov sp, 0x7C00
更改为 mov esp, 0x7C00
。这确保完整的 32 位堆栈指针为 0x7c00。
修改后的 kmain.c
代码现在应该如下所示:
#include <stdint.h>
void print_str(char *str) {
while (*str) {
/* AH=0x0e, AL=char to print, BH=page, BL=fg color */
__asm__ __volatile__ ("int [=23=]x10"
:
: "a" ((0x0e<<8) | *str++),
"b" (0x0000));
}
}
和hw.asm
:
extern print_str
global start
bits 16
section .text
start:
xor ax, ax ; AX = 0
mov ds, ax
mov es, ax
mov ss, ax
mov esp, 0x7C00
jmp 0x0000:setcs ; Set CS to 0
setcs:
cld ; GCC code requires direction flag to be cleared
push dword welcome
call dword print_str ; call function
cli
hlt
section .data
welcome db 'Developped by Marius Van Nieuwenhuyse', 0x0D, 0x0A, 0
可以使用这些命令构建引导加载程序:
gcc -fno-PIC -ffreestanding -m16 -c kmain.c -o kmain.o
ld -melf_i386 --build-id=none -T link.ld kmain.o hw.o -o kernel.elf
objcopy -O binary kernel.elf kernel.bin
当 运行 和 qemu-system-i386 -fda kernel.bin
看起来应该类似于:
在大多数情况下,GCC 生成的代码需要 80386+
GCC 使用 .code16gcc
生成的代码有很多缺点:
- ES=DS=CS=SS 必须为 0
- 代码必须适合前 64kb
- GCC 代码不理解 20 位 segment:offset 寻址。
- 除了最琐碎的 C 代码之外,GCC 不会生成可以在 286/186/8086 上 运行 的代码。它 运行 处于实模式,但它使用 32 位操作数和寻址,在早于 80386 的处理器上不可用。
- 如果你想访问第一个 64kb 以上的内存位置,那么你需要在调用 C 代码之前进入 Unreal Mode(big)。
如果您想从更现代的 C 编译器生成真正的 16 位代码,我推荐 OpenWatcom C
- 内联汇编不如GCC 强大
- 内联汇编语法不同,但它比 GCC 的内联汇编更易于使用且不易出错。
- 可以生成可在过时的 8086/8088 处理器上 运行 的代码。
- 理解 20 位 segment:offset 实模式寻址并支持远指针和大指针的概念。
wlink
Watcom linker 可以生成可用作引导加载程序的基本平面二进制文件。
零填充 BSS 部分
BIOS 启动顺序不能保证内存实际上为零。这会导致零初始化区域 BSS 出现潜在问题。在第一次调用 C 代码之前,该区域应该由我们的汇编代码填充为零。我最初编写的 linker 脚本定义了一个符号 __bss_start
,它是 BSS 内存的偏移量,而 __bss_sizeb
是以字节为单位的大小。使用此信息,您可以使用 STOSB 指令轻松将其填零。在 hw.asm
的顶部您可以添加:
extern __bss_sizeb
extern __bss_start
并且在 CLD 指令之后和调用任何 C 代码之前,您可以通过这种方式进行零填充:
; Zero fill the BSS section
mov cx, __bss_sizeb ; Size of BSS computed in linker script
mov di, __bss_start ; Start of BSS defined in linker script
rep stosb ; AL still zero, Fill memory with zero
其他建议
为了减少编译器生成的代码的膨胀,使用 -fomit-frame-pointer
会很有用。使用 -Os
编译可以优化 space(而不是速度)。我们限制了 BIOS 加载的初始代码的 space(512 字节),因此这些优化可能是有益的。编译命令行可能显示为:
gcc -fno-PIC -fomit-frame-pointer -ffreestanding -m16 -Os -c kmain.c -o kmain.o