问题

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

回答
C 语言本身并不能直接“编译出一个不需要操作系统的程序”,因为它需要一个运行环境。更准确地说,C 语言本身是一种编译型语言,它将源代码转换为机器码,而机器码的执行是依赖于硬件的。

然而,当人们说“不需要操作系统的程序”时,通常指的是以下几种情况,而 C 语言可以用来实现它们:

1. 嵌入式系统中的裸机(Bare Metal)程序: 这是最接近“不需要操作系统”概念的场景。在嵌入式设备(如微控制器、物联网设备)上,通常没有完整的操作系统。C 语言被广泛用于直接控制硬件,编写那些在启动后直接运行的程序。这些程序直接与硬件交互,绕过了操作系统的抽象层。

2. 引导加载程序 (Bootloader): 计算机启动时,最先执行的一小段代码,它负责初始化硬件,然后加载操作系统的核心。引导加载程序通常也是用 C 编写的,并且需要在没有操作系统的情况下运行。

3. 用户模式下的独立可执行文件: 在一个有操作系统的环境中,也可以编译出不依赖于任何特定系统库(或只依赖极少量的标准库)的独立可执行文件。这种文件可以被认为在某种程度上“不依赖于操作系统”,因为它不依赖于高级的操作系统服务,而是直接与操作系统提供的底层接口(系统调用)打交道。

下面我们将重点关注 嵌入式系统中的裸机程序,因为这是最符合“不需要操作系统”字面意思的场景,并详细讲解如何使用 C 语言来实现。

什么是裸机(Bare Metal)编程?

裸机编程指的是直接在硬件上运行程序,不依赖于任何操作系统或固件层。在这种环境下,你的 C 代码是第一个被执行的程序,它需要负责:

初始化硬件: 配置 CPU、内存、时钟、外设(如串口、定时器、GPIO 等)。
管理内存: 分配和使用堆栈、全局变量、静态变量等。
中断处理: 编写中断服务例程 (ISR) 来响应硬件事件。
任务调度(如果需要): 如果是稍微复杂一些的嵌入式系统,可能需要自己编写一个简单的任务调度器,而不是依赖操作系统的多任务调度。

如何用 C 语言编写裸机程序?

要用 C 语言编写裸机程序,你需要:

1. 目标硬件平台: 你需要一个具体的硬件平台,例如一个微控制器(如 ARM CortexM 系列的 STM32、ESP32,AVR 系列的 Arduino Uno 使用的 ATmega328P 等)。不同的硬件有不同的寄存器和内存映射。

2. 交叉编译工具链: 你需要在你的开发主机(通常是 PC,运行 Windows, Linux, macOS)上安装一个 交叉编译工具链。这是因为你的开发主机和目标硬件是不同的架构(例如,你的 PC 可能是 x86 架构,而目标微控制器可能是 ARM 架构)。交叉编译工具链可以将 C 代码编译成目标硬件能够理解的机器码。

GCC for ARM 是一个非常流行的选择,许多嵌入式开发都会用到它。
你需要获取针对你目标 CPU 架构(如 `armnoneeabi`)的 GCC 工具链。

3. 链接脚本(Linker Script): 这是裸机编程中至关重要的一环。操作系统的链接器通常会链接到标准的运行时库,这些库处理了 C 的启动代码、内存布局等细节。在裸机编程中,你需要自己定义程序的内存布局,包括代码段 `.text`、初始化数据段 `.data`、未初始化数据段 `.bss` 的起始地址和大小。链接脚本告诉链接器如何将编译后的对象文件组合起来,并放置在目标硬件的特定内存区域。

4. 启动代码(Startup Code): C 语言的全局变量和静态变量需要在程序开始执行之前被初始化(例如,`.data` 段需要从闪存复制到 RAM,`.bss` 段需要清零)。此外,还需要设置堆栈指针。这些初始化工作通常由一个小的汇编程序来完成,这个汇编程序也是裸机程序的一部分。它会在 C 的 `main` 函数被调用之前执行。

5. 硬件抽象层(HAL)或直接寄存器访问: 你需要通过直接读写硬件寄存器来控制硬件。你可以自己编写 C 函数来封装这些寄存器操作(这构成了简单的硬件抽象层),或者直接在代码中使用宏来访问寄存器地址。

流程详解

假设我们以一个简单的 ARM CortexM 系列微控制器(例如 STM32F103)为例,讲解如何编译出一个裸机程序:

1. C 语言源文件(例如 `main.c`):

```c
// 示例:一个简单的裸机程序,用于点亮一个 LED
// 需要假设我们知道 LED 连接到哪个 GPIO 端口和引脚,以及对应的寄存器地址和位。

// 假设 LED 连接到 PA5 (Port A, Pin 5)
// GPIOA 的基地址和 RCC (Reset and Clock Control) 的基地址需要查阅微控制器的参考手册。

// 示例寄存器定义 (这些地址是示意性的,实际地址需要查阅 Datasheet)
define PERIPH_BASE 0x40000000UL
define AHB1_PERIPH_BASE (PERIPH_BASE + 0x00020000UL) // AHB1 bus base address for CortexM3/M4

define GPIOA_BASE (AHB1_PERIPH_BASE + 0x00000000UL)
define RCC_BASE (PERIPH_BASE + 0x00003800UL) // RCC base address

// RCC Registers
define RCC_AHB1ENR ((volatile unsigned int)(RCC_BASE + 0x30UL)) // AHB1 Peripheral Clock Enable Register

// GPIO Registers
define GPIOA_MODER ((volatile unsigned int)(GPIOA_BASE + 0x00UL)) // Port Mode Register
define GPIOA_ODR ((volatile unsigned int)(GPIOA_BASE + 0x14UL)) // Port Output Data Register

// Bit definitions for RCC_AHB1ENR
define RCC_AHB1ENR_GPIOAEN (1UL << 0) // GPIOA clock enable bit

// Bit definitions for GPIOA_MODER
// For PA5, we want it to be an output.
// Bits 11:10 of MODER register control PA5.
// 00: Input, 01: Output, 10: Alternate Function, 11: Analog
// Setting to 01 (output) requires writing 01 to bits 11:10.
define GPIOA_MODER_PA5_OUT (1UL << (5 2)) // PA5 mode is output (bits 11:10)

// Bit definitions for GPIOA_ODR
define GPIOA_ODR_PA5 (1UL << 5) // PA5 output pin

void delay(volatile unsigned int count) {
while (count);
}

int main() {
// 1. 使能 GPIOA 时钟
// 通过设置 RCC_AHB1ENR 寄存器的 GPIOAEN 位来打开 GPIOA 的时钟
RCC_AHB1ENR |= RCC_AHB1ENR_GPIOAEN;

// 2. 配置 PA5 为输出模式
// 清零 PA5 的模式位 (bits 11:10), 然后设置它们为输出模式 (01)
GPIOA_MODER &= ~(0x3UL << (5 2)); // Clear bits 11:10
GPIOA_MODER |= GPIOA_MODER_PA5_OUT; // Set bits 11:10 to 01 for output

// 3. 主循环:循环点亮和熄灭 LED
while (1) {
// 点亮 LED (设置 PA5 的输出为高电平)
GPIOA_ODR |= GPIOA_ODR_PA5;
delay(100000); // 延时

// 熄灭 LED (设置 PA5 的输出为低电平)
GPIOA_ODR &= ~GPIOA_ODR_PA5;
delay(100000); // 延时
}

return 0; // 理论上裸机程序不会返回
}
```

2. 汇编启动代码(例如 `startup.s`):

这个文件负责设置堆栈,然后调用 C 的 `main` 函数。

```assembly
.section .vectors
.word _stack_top @ Initial Stack Pointer
.word Reset_Handler @ Reset Vector

.section .text
.global Reset_Handler
Reset_Handler:
@ Set up the stack pointer
ldr r0, =_stack_top
mov sp, r0

@ Initialize .data section (copy initialized data from Flash to RAM)
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =__etext
mov r3, r0 @ r3 = destination buffer (RAM)
mov r0, r2 @ r0 = source address (Flash)
add r0, r0, 8 @ Adjust source address to copy actual data after vector table and code
cmp r3, r1
bge init_bss @ If RAM is not different from Flash, skip copy

copy_data_loop:
ldr r4, [r0], 4
str r4, [r3], 4
cmp r3, r1
blt copy_data_loop

init_bss:
@ Clear the .bss section (uninitialized data)
ldr r0, =_sbss
ldr r1, =_ebss
mov r2, 0 @ Zero value
clear_bss_loop:
cmp r0, r1
bge main_call

str r2, [r0], 4
b clear_bss_loop

main_call:
@ Call the main function
bl main

@ Infinite loop if main returns (which it shouldn't in bare metal)
halt_loop:
b halt_loop

.size Reset_Handler, .Reset_Handler

.global _stack_top
.equ _stack_top, __initial_sp
```

注意: `_stack_top`, `_sdata`, `_edata`, `__etext`, `_sbss`, `_ebss`, `__initial_sp` 这些符号的定义将会在链接脚本中给出。

3. 链接脚本(例如 `linker.ld`):

这是最关键的部分,它定义了程序的内存布局。你需要查阅目标微控制器的内存图来确定 Flash 和 RAM 的起始地址和大小。

```ld
/ Linker script for a generic ARM CortexM microcontroller /

/ Define memory regions (these are illustrative, check your MCU's datasheet) /
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K / Example FLASH start and size /
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K / Example RAM start and size /
}

/ Define entry point /
ENTRY(Reset_Handler)

/ Define stack top /
_stack_top = ORIGIN(RAM) + LENGTH(RAM); / Stack grows downwards from the top of RAM /
__initial_sp = _stack_top;

/ Define output sections /
SECTIONS
{
/ Vector table and startup code /
.vectors :
{
. = ALIGN(4);
KEEP((.vectors)) / Keep the .vectors section /
} > FLASH

/ Code section /
.text :
{
. = ALIGN(4);
(.text) / All .text sections /
(.text) / All .text sections /
(.rodata) / Readonly data /
(.rodata) / Readonly data /
. = ALIGN(4);
__etext = .; / End of code and readonly data /
} > FLASH

/ Initialized data section /
.data : AT(__etext) / Place .data section after .text, with its load address at __etext /
{
. = ALIGN(4);
_sdata = .; / Start of initialized data /
(.data) / All .data sections /
(.data) / All .data sections /
. = ALIGN(4);
_edata = .; / End of initialized data /
} > RAM / Load into RAM, but at the address specified by AT(__etext) /

/ Uninitialized data section (BSS) /
.bss :
{
. = ALIGN(4);
_sbss = .; / Start of BSS /
(.bss) / All .bss sections /
(.bss) / All .bss sections /
. = ALIGN(4);
_ebss = .; / End of BSS /
} > RAM

/ Stack definition is usually handled by _stack_top for the entry point /
/ But sometimes you might define a separate stack section if needed /
}
```

4. 编译和链接命令:

你需要使用交叉编译工具链来完成编译和链接。假设你的交叉编译工具链前缀是 `armnoneeabi`。

编译 C 文件:
```bash
armnoneeabigcc mcpu=cortexm3 mthumb c main.c o main.o Wall O2 ffreestanding nostdlib
```
`mcpu=cortexm3`: 指定目标 CPU 类型。
`mthumb`: 指定使用 Thumb 指令集(大多数嵌入式 ARM 使用)。
`c`: 只编译不链接。
`Wall`: 开启所有警告。
`O2`: 优化级别 2。
`ffreestanding`: 告诉编译器这是一个“自由站立”的环境,没有标准库支持。
`nostdlib`: 不要链接标准库(这很重要!)。

编译汇编文件:
```bash
armnoneeabigcc mcpu=cortexm3 mthumb c startup.s o startup.o
```

链接:
```bash
armnoneeabigcc mcpu=cortexm3 mthumb T linker.ld startup.o main.o o program.elf
```
`T linker.ld`: 指定使用的链接脚本。
`startup.o main.o`: 需要链接的对象文件。
`o program.elf`: 输出 ELF 格式的可执行文件。

5. 生成目标格式(例如二进制文件):

ELF 文件通常不能直接烧录到微控制器中,你需要将其转换为其他格式,如二进制文件(`.bin`)或 Intel HEX 文件(`.hex`)。

```bash
armnoneeabiobjcopy O binary program.elf program.bin
或者生成 HEX 文件
armnoneeabiobjcopy O ihex program.elf program.hex
```

6. 烧录和运行:

最后,你需要使用一个烧录工具(如 JLink、STLink、OpenOCD 等)将 `program.bin` 或 `program.hex` 文件烧录到你的微控制器上。一旦烧录完成,当你复位微控制器时,你的裸机程序就会在没有操作系统的环境中直接运行。

为什么 `nostdlib` 和 `ffreestanding` 很重要?

`nostdlib`: 这个选项告诉链接器不要自动链接 C 标准库。在裸机环境中,没有 C 标准库(`libc`)的支持。如果你不使用这个选项,链接器会尝试寻找并链接它,导致链接失败,或者产生一个依赖于标准库但你又无法提供的程序。
`ffreestanding`: 这个选项告诉编译器我们处于一个“自由站立”的环境。这意味着编译器不能假设某些标准函数(如 `memcpy`, `memset`)是可用的,也不能假设有标准 C 运行时环境的支持。编译器会生成更底层的代码,并依赖于你提供的启动代码来完成初始化。

总结

通过上述步骤,你就可以使用 C 语言编写一个不需要操作系统的程序,也就是一个裸机程序。核心在于:

了解目标硬件的内存映射和寄存器。
使用交叉编译工具链。
编写汇编启动代码来完成低级初始化。
编写链接脚本来精确控制程序的内存布局。
使用 `nostdlib` 和 `ffreestanding` 编译器选项。

这种编程方式提供了对硬件的极致控制,但也需要开发者处理更多底层细节,包括硬件初始化、中断管理、内存管理等。

网友意见

user avatar

来个更短的,没有其他乱七八糟的东西,只有一个简短的 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

现代 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 的时候用自制或者通用工具得到)。

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有