深入探讨现代 Hello World 程序背后的抽象世界
在我们开始之前
本文是关于用 C 编写的 Hello World 程序。这大约是您作为高级语言所能达到的最高水平,而不必担心您的特定语言在 Hello World 之前在解释器/编译器/JIT 中正在做什么正确的实际运行。
我最初写这篇文章的目的是让任何有一定编码背景的人都能理解它,但我现在认为至少拥有一些 C 或汇编知识会很有帮助。
开始
每个人都应该熟悉 Hello World 程序。在 python 中,您编写的第一个程序可能是:
python
print('Hello World!')
它只是打印文本"Hello World!"到屏幕上。
在本文中,我们将研究 C 编程语言中的 Hello World。如果你不熟悉的话,那就是:
c
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
该程序与 python 程序执行完全相同的操作。然而,与 python 不同的是,你不能只调用解释器来运行这个程序。你必须先运行编译器,将这段代码转换成计算机处理器可以直接运行的机器代码。所有使计算机工作的现代大型重要程序都是这样编写的。
为此,我们运行以下命令:
shell
gcc hello.c -o hello
这将从文件 hello.c
中获取我们的 C 代码,并在名为 hello
的文件中生成机器代码程序。然后我们可以通过运行以下命令来运行它:
shell
./hello
输出:
Hello World!
我们的计划
好的,那它是怎么做到的呢?嗯,首先要看的是我们的程序。到底是什么?
shell
$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b74da2c9c77d221eeaa98f87f4a7a529782db280, for GNU/Linux 3.2.0, not stripped
这大多是我们不会担心的事情,或者以后才需要担心的事情。重要的部分只是
ELF executable, x86-64 ELF 可执行文件,x86-64
这告诉我们该程序是x86_64指令集架构的ELF可执行文件。这意味着什么?
ELF 可执行文件在 Linux 中相当于 Windows .exe
文件。它只是您的计算机可以运行的程序。但我们已经知道了。另一部分告诉我们,它是一个机器代码程序,旨在在 64 位 x86 处理器上运行,这是自 1981 年 IBM PC 推出以来一直在 PC 中使用的 CPU 架构。那不是 64 位请注意,但我们的现代处理器仍然可以运行为 IBM PC 编写的代码(某种程度)。我离题了。
所以这个文件包含机器代码,一种语言,也是CPU可以理解的唯一语言。那么CPU从哪里开始运行它的代码呢?
shell
$ readelf -h hello
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x1060 Start of program headers: 64 (bytes into file) Start of section headers: 13976 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 13 Size of section headers: 64 (bytes) Number of section headers: 31 Section header string table index: 30
这里重要的部分是 Entry point address:
,它设置为 0x1060
。这是一个十六进制数字,代表我们程序中的位置,或者一旦加载程序,就代表我们计算机内存中的位置。那么到底有什么?
代码
shell
$ objdump -D hello
我不会将该命令的整个输出放在这里,因为它太长了。但是如果我们滚动它,我们最终会找到一些文本行,其中第一行以 1060:
开头
perl
Disassembly of section .text:
0000000000001060 <_start>:
1060: f3 0f 1e fa endbr64
1064: 31 ed xor %ebp,%ebp
1066: 49 89 d1 mov %rdx,%r9
1069: 5e pop %rsi
106a: 48 89 e2 mov %rsp,%rdx
106d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
1071: 50 push %rax
1072: 54 push %rsp
1073: 45 31 c0 xor %r8d,%r8d
1076: 31 c9 xor %ecx,%ecx
1078: 48 8d 3d ca 00 00 00 lea 0xca(%rip),%rdi # 1149 <main>
107f: ff 15 53 2f 00 00 call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
1085: f4 hlt
1086: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
108d: 00 00 00
这是什么意思?冒号之前的第一个数字是后续字节的地址,本质上是它们在文件中的位置。接下来的数字是程序文件中的数据字节,在本例中代表机器代码。以下文本是该代码的反汇编。汇编语言是人类可读的机器代码表示。请注意,即使左侧的字节不代表代码,反汇编器仍会尝试反汇编它们。这会导致垃圾和无意义的汇编代码。
所以我们找到了一些代码!但不是我们编写的代码。它由编译器(技术上是链接器)自动添加到我们的程序中。基本上,这段代码会进行一些初始化,然后运行一条重要的指令:
scss
call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
该指令告诉计算机去其他地方执行一些代码,在本例中是在地址 0x2f53
处执行,当动态链接器加载我们的程序时,该地址将更改为地址 0x3fd8
。我不会深入讨论这个。
但无论您如何努力查找,您都无法在我们的文件中找到这些地址中的任何一个。从技术上讲, 0x3fd8
位于全局偏移表中,这超出了本文的范围,但它现在是空的。那是因为这段代码没有在我们的程序中定义,它在其他地方。
The C library
那么它在哪里呢?
shell
$ readelf -d hello
Dynamic section at offset 0x2dc8 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x1168
0x0000000000000019 (INIT_ARRAY) 0x3db8
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3dc0
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x3b0
0x0000000000000005 (STRTAB) 0x480
0x0000000000000006 (SYMTAB) 0x3d8
0x000000000000000a (STRSZ) 141 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fb8
0x0000000000000002 (PLTRELSZ) 24 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x610
0x0000000000000007 (RELA) 0x550
0x0000000000000008 (RELASZ) 192 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
0x000000006ffffffe (VERNEED) 0x520
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x50e
0x000000006ffffff9 (RELACOUNT) 3
0x0000000000000000 (NULL) 0x0
这是我们的代码所依赖的库的列表。在这种情况下,我们看到这一行
ini
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
这是我们系统的标准 C 库,是我们计算机上几乎所有程序使用的例程和函数的集合。在 Windows 领域,这相当于 C 运行时, msvcrt.dll
或 ucrt<something>.dll
。需要注意的一件事是,Linux 中扩展名为 .so
的文件(称为共享对象)相当于 Windows 中扩展名为 .dll
的文件(称为动态链接库)。它们都包含可以在多个程序之间共享的代码。
所以我们可以重复使用 objdump
来查找这段代码在我们的 C 库中的位置以及它的作用,但是 C 库庞大而复杂,我们甚至还没有找到代码我们还写过。所以我会帮你省去麻烦:它会进行一些初始化,例如获取程序的命令行参数和环境变量,并调用我们的 main()
函数。然后,当我们从 main()
返回时,它会使用我们提供的状态代码退出我们的程序。
那么我们的main function在哪里呢?
main()
当然,它在我们的计划中。回到我们的反汇编,我们看到:
perl
0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 8d 05 ac 0e 00 00 lea 0xeac(%rip),%rax # 2004 <_IO_stdin_used+0x4>
1158: 48 89 c7 mov %rax,%rdi
115b: e8 f0 fe ff ff call 1050 <puts@plt>
1160: b8 00 00 00 00 mov $0x0,%eax
1165: 5d pop %rbp
1166: c3 ret
最后,我们的代码!那么它有什么作用呢?它:
- 设置堆栈框架
- 设置函数调用的参数
- 调用我们的 Hello World
- 清理堆栈框架
- 从退出代码为 0 的函数返回
这是我们在源代码中看到的。但什么是栈帧呢?它是计算机内存的一部分,我们的程序用它来存储局部变量,即在主函数中声明的变量。幸运的是,我们没有声明任何变量,所以我们实际上不必担心这一点。这里重要的部分是:
nasm
lea 0xeac(%rip),%rax
call 1050 <puts@plt>
These instructions: 这些说明:
- 将 Hello World 字符串的内存地址设置为函数调用的第一个参数(间接)
- 调用
puts()
函数
等等, puts()
?我们不是调用了 printf()
吗?
是的。但是,编译器执行了优化。 printf 函数很复杂,因为它能够打印"格式化输出",这意味着我们可以在输出中嵌入变量。该函数将处理将它们转换为字符串并为我们打印它们,但我们没有使用其中任何一个。因此,编译器将 printf()
替换为更简单的 puts()
,它只打印一串未格式化的文本。那么我们的文本在哪里呢?
The string
根据反汇编程序,它位于地址 0x0eac
中,加载时会转换为地址 0x2004
。那看起来是什么样子的呢?
perl
Disassembly of section .rodata:
0000000000002000 <_IO_stdin_used>:
2000: 01 00 add %eax,(%rax)
2002: 02 00 add (%rax),%al
2004: 48 rex.W
2005: 65 6c gs insb (%dx),%es:(%rdi)
2007: 6c insb (%dx),%es:(%rdi)
2008: 6f outsl %ds:(%rsi),(%dx)
2009: 20 57 6f and %dl,0x6f(%rdi)
200c: 72 6c jb 207a <__GNU_EH_FRAME_HDR+0x66>
200e: 64 21 00 and %eax,%fs:(%rax)
还记得我之前说过反汇编程序会尝试反汇编代码,即使它不是代码吗?这是一个很好的例子。忽略汇编语言,它完全是胡言乱语。但是如果我们查看地址 0x2004
,我们会看到十六进制字节 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
,它会转换为字符串"Hello World!",后跟一个 NULL 终止符。
但是我们的字符串不是还包含一个换行符 \n
吗?它应该被转换为 ASCII 0x0a
吗?是的,但这是编译器优化的另一个产物。 puts()
函数打印出带有尾随换行符的字符串,而 printf()
则不会。所以它删除了我们的换行符,所以我们最终在输出中只得到一个换行符。
然后我们看到一个 0x00
NULL 字节。这称为 NULL 终止符,它出现在所有 C 字符串的末尾。在 C 中,我们的字符串不与任何长度信息相关联。因此,采用任意长度的字符串作为参数的函数将一次对它执行一个字节,直到它看到 NULL 终止符。如果内存中有多个字符串,并且它们之间没有 NULL 终止符,那么 C 函数将同时对所有字符串进行操作。最终,这些函数将到达末尾并开始读取它们不允许读取的内存,并且您的程序将因可怕的"Segmentation Fault"而崩溃。
following the puts()
所以 put() 位于 0x1050
。
perl
Disassembly of section .plt.sec:
0000000000001050 <puts@plt>:
1050: f3 0f 1e fa endbr64
1054: f2 ff 25 75 2f 00 00 bnd jmp *0x2f75(%rip) # 3fd0 <puts@GLIBC_2.2.5>
105b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
好的,现在它正在回调标准库。 (从技术上讲是全局偏移表,但最终是标准库)
再说一次,我们不想阅读标准库的反汇编,但幸运的是 Glibc(我们的 C 标准库)是开源的。那么这会带我们去哪里呢?
好吧,puts() 是标准库中函数 _IO_puts 的别名。
c
int
_IO_puts (const char *str)
{
int result = EOF;
size_t len = strlen (str);
_IO_acquire_lock (stdout);
if ((_IO_vtable_offset (stdout) != 0
|| _IO_fwide (stdout, -1) == -1)
&& _IO_sputn (stdout, str, len) == len
&& _IO_putc_unlocked ('\n', stdout) != EOF)
result = MIN (INT_MAX, len + 1);
_IO_release_lock (stdout);
return result;
}
因此它获取字符串的长度,获取输出流的锁,进行一些检查,然后调用 _IO_sputn。然后它释放锁并返回打印的字符数。
我搜索了这个功能,但没有找到。显然它通过一个名为 _IO_file_jumps 的函数执行某些操作,并调用 _IO_new_file_xsputn。
c
size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
const char *s = (const char *) data;
size_t to_do = n;
int must_flush = 0;
size_t count = 0;
if (n <= 0)
return 0;
/* This is an optimized implementation. If the amount to be written straddles a block boundary (or the filebuf is unbuffered), use sys_write directly. */
/* First figure out how much space is available in the buffer. */
if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
{
count = f->_IO_buf_end - f->_IO_write_ptr;
if (count >= n)
{
const char *p;
for (p = s + n; p > s; )
{
if (*--p == '\n')
{
count = p - s + 1;
must_flush = 1;
break;
}
}
}
}
else if (f->_IO_write_end > f->_IO_write_ptr)
count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */
/* Then fill the buffer. */
if (count > 0)
{
if (count > to_do)
count = to_do;
f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
s += count;
to_do -= count;
}
if (to_do + must_flush > 0)
{
size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF)
/* If nothing else has to be written we must not signal the caller that everything has been written. */
return to_do == 0 ? EOF : n - to_do;
/* Try to maintain alignment: write a whole number of blocks. */
block_size = f->_IO_buf_end - f->_IO_buf_base;
do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);
if (do_write)
{
count = new_do_write (f, s, do_write);
to_do -= count;
if (count < do_write)
return n - to_do;
}
/* Now write out the remainder. Normally, this will fit in the buffer, but it's somewhat messier for line-buffered files, so we let _IO_default_xsputn handle the general case. */
if (to_do)
to_do -= _IO_default_xsputn (f, s+do_write, to_do);
}
return n - to_do;
}
哇。所有这一切都是为了一个 Hello World。我不会尝试理解这段代码是如何工作的,即使有注释。所以此时我意识到使用 Glibc 来解释这将是一件痛苦的事情。所以在这里,我决定看看 musl libc,我知道它应该更小。
musl
所以在musl中,puts()定义如下:
c
int puts(const char *s)
{
int r;
FLOCK(stdout);
r = -(fputs(s, stdout) < 0 || putc_unlocked('\n', stdout) < 0);
FUNLOCK(stdout);
return r;
}
好的,它获得了输出流的锁,调用 fputs,并解锁输出流。
fputs() 是如何定义的?
c
#include "stdio_impl.h"
#include <string.h>
int fputs(const char *restrict s, FILE *restrict f)
{
size_t l = strlen(s);
return (fwrite(s, 1, l, f)==l) - 1;you.
}
它获取字符串的长度,并使用输出流、字符串及其长度调用 fwrite()。
fwrite() 是如何定义的?
c
size_t fwrite(const void *restrict src, size_t size, size_t nmemb, FILE *restrict f)
{
size_t k, l = size*nmemb;
if (!size) nmemb = 0;
FLOCK(f);
k = __fwritex(src, l, f);
FUNLOCK(f);
return k==l ? nmemb : k/size;
}
它在输出流上获得另一个锁,调用 __fwritex(),并解锁输出流。
__fwritex() 是如何定义的?
c
size_t __fwritex(const unsigned char *restrict s, size_t l, FILE *restrict f)
{
size_t i=0;
if (!f->wend && __towrite(f)) return 0;
if (l > f->wend - f->wpos) return f->write(f, s, l);
if (f->lbf >= 0) {
/* Match /^(.*\n|)/ */
for (i=l; i && s[i-1] != '\n'; i--);
if (i) {
size_t n = f->write(f, s, i);
if (n < i) return n;
s += i;
l -= i;
}
}
memcpy(f->wpos, s, l);
f->wpos += l;
return l+i;
}
这是相当多的代码,但它的主要作用是在输出流的 FILE 对象上调用 write()。我们的流被定义为stdout,那么它是在哪里定义的呢?
c
hidden FILE __stdout_FILE = {
.buf = buf+UNGET,
.buf_size = sizeof buf-UNGET,
.fd = 1,
.flags = F_PERM | F_NORD,
.lbf = '\n',
.write = __stdout_write,
.seek = __stdio_seek,
.close = __stdio_close,
.lock = -1,
};
所以写函数定义为__stdout_write()。这是如何定义的?
c
size_t __stdout_write(FILE *f, const unsigned char *buf, size_t len)
{
struct winsize wsz;
f->write = __stdio_write;
if (!(f->flags & F_SVB) && __syscall(SYS_ioctl, f->fd, TIOCGWINSZ, &wsz))
f->lbf = -1;
return __stdio_write(f, buf, len);
}
它在输出流上创建 TIOCGWINSZ ioctl,并调用 __stdio_write()。这是如何定义的?
c
size_t __stdio_write(FILE *f, const unsigned char *buf, size_t len)
{
struct iovec iovs[2] = {
{ .iov_base = f->wbase, .iov_len = f->wpos-f->wbase },
{ .iov_base = (void *)buf, .iov_len = len }
};
struct iovec *iov = iovs;
size_t rem = iov[0].iov_len + iov[1].iov_len;
int iovcnt = 2;
ssize_t cnt;
for (;;) {
cnt = syscall(SYS_writev, f->fd, iov, iovcnt);
if (cnt == rem) {
f->wend = f->buf + f->buf_size;
f->wpos = f->wbase = f->buf;
return len;
}
if (cnt < 0) {
f->wpos = f->wbase = f->wend = 0;
f->flags |= F_ERR;
return iovcnt == 2 ? 0 : len-iov[0].iov_len;
}
rem -= cnt;
if (cnt > iov[0].iov_len) {
cnt -= iov[0].iov_len;
iov++; iovcnt--;
}
iov[0].iov_base = (char *)iov[0].iov_base + cnt;
iov[0].iov_len -= cnt;
}
}
我们现在正处于冲刺阶段。这做了很多事情,但它使用 SYS_writev 作为其第一个参数来调用 syscall()。那么syscall()是如何定义的呢?
c
long syscall(long n, ...)
{
va_list ap;
syscall_arg_t a,b,c,d,e,f;
va_start(ap, n);
a=va_arg(ap, syscall_arg_t);
b=va_arg(ap, syscall_arg_t);
c=va_arg(ap, syscall_arg_t);
d=va_arg(ap, syscall_arg_t);
e=va_arg(ap, syscall_arg_t);
f=va_arg(ap, syscall_arg_t);
va_end(ap);
return __syscall_ret(__syscall(n,a,b,c,d,e,f));
}
syscall() 将系统调用号作为其第一个参数,以及数量可变的附加参数。 va_arg() 调用将这些参数读入变量 a、b、c、d、e 和 f。然后我们用这些参数调用 __syscall(),结果进入 __syscall_ret()。
不幸的是,我找不到 __syscall() 的定义,但我觉得这是因为我们正在进入特定于平台的领域。 Musl 是一个多架构 C 库,因此从现在开始运行的代码取决于我们使用的架构。在深入研究之前,我查看了 __syscall_ret():
c
long __syscall_ret(unsigned long r)
{
if (r > -4096UL) {
errno = -r;
return -1;
}
return r;
}
它只是检查 __syscall() 的返回值是否有效,如果无效,则系统调用失败,因此返回 -1。
System Calls
因此,Hello World 调用的最后几个阶段涉及系统调用。什么是系统调用?好吧,无论我们的 C 库有多大,有些事情它永远无法为我们做。其中之一就是与硬件对话。执行此操作的能力是为内核保留的,内核是操作系统的一部分,用于控制和共享对 IO 设备、内存和 CPU 时间的访问。在我们的例子中,这是 Linux 内核。在 Windows 世界中,这是 ntoskrnl.exe
,它在任务管理器中显示为"系统"。
这意味着我们的 put() 调用必须以我们告诉操作系统为我们做一些事情来结束。在这种情况下,我们要求操作系统将一些文本写入输出流。写入流是通过 write
系统调用完成的。 Musl 使用类似的系统调用 writev
,它可以在数组中写入多个缓冲区。那么让我们看看 musl 是如何进行系统调用的。
c
static __inline long __syscall0(long n)
{
unsigned long ret;
__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n) : "rcx", "r11", "memory");
return ret;
}
static __inline long __syscall1(long n, long a1)
{
unsigned long ret;
__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1) : "rcx", "r11", "memory");
return ret;
}
static __inline long __syscall2(long n, long a1, long a2)
{
unsigned long ret;
__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2)
: "rcx", "r11", "memory");
return ret;
}
static __inline long __syscall3(long n, long a1, long a2, long a3)
{
unsigned long ret;
__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
"d"(a3) : "rcx", "r11", "memory");
return ret;
}
static __inline long __syscall4(long n, long a1, long a2, long a3, long a4)
{
unsigned long ret;
register long r10 __asm__("r10") = a4;
__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
"d"(a3), "r"(r10): "rcx", "r11", "memory");
return ret;
}
static __inline long __syscall5(long n, long a1, long a2, long a3, long a4, long a5)
{
unsigned long ret;
register long r10 __asm__("r10") = a4;
register long r8 __asm__("r8") = a5;
__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
"d"(a3), "r"(r10), "r"(r8) : "rcx", "r11", "memory");
return ret;
}
static __inline long __syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6)
{
unsigned long ret;
register long r10 __asm__("r10") = a4;
register long r8 __asm__("r8") = a5;
register long r9 __asm__("r9") = a6;
__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
"d"(a3), "r"(r10), "r"(r8), "r"(r9) : "rcx", "r11", "memory");
return ret;
}
我们已经到达底部了。这些是 musl 用于在 x86_64 平台上进行系统调用的 7 个不同函数。每个系统调用都采用不同数量的参数。
每个函数都有一个 asm 指令。这会将内联汇编代码嵌入到编译器的机器语言输出中。我们通过使用参数设置一些 CPU 寄存器并执行 syscall
指令来对操作系统进行系统调用。然后控制权被传递给内核,内核读取我们的参数并执行我们的系统调用。
The kernel
Linux 内核现在必须执行系统调用请求的操作。 write
系统调用告诉内核写入文件系统上打开的文件,或者写入流,这就是我们在本例中所做的。
write
系统调用需要 3 个参数:要写入的文件描述符、要写入的缓冲区以及要写入的字节数。 musl 使用的 writev
系统调用有所不同,但现在让我们重点关注 write
。
那么我们到底要写到哪里呢?
shell
$ ps
PID TTY TIME CMD
15705 pts/0 00:00:00 bash
23332 pts/0 00:00:00 ps
$ cd /proc/15705/fd
$ readlink 1
/dev/pts/0
就我而言,我在 GNOME 终端模拟器(一个图形应用程序)中运行 hello
程序。它对内核来说就像一个伪终端 (pty)。因此,内核将我们的 Hello World 消息保存在缓冲区中,当终端仿真器程序运行时,它会读取并显示它。瞧。
当然,我们还没有完成。然后,终端模拟器必须将文本渲染到一个框架中(可能使用 GPU 来完成),将该框架发送到 X 服务器/合成器,它将它与我运行的其他应用程序(也使用 GPU)结合起来,例如我用来编写此内容的文本编辑器,并将其发送回内核,然后内核显示它。
谢什。我在那里掩盖了很多,因为这并不重要,而且对你来说可能完全不同。也许您已远程登录,在这种情况下,内核会将您的文本发送到 sshd
,然后将其以数据包形式(加密)发送回内核,以便通过互联网发送。也许您正在使用连接到串行至 USB 适配器的物理终端。然后内核必须将文本放入 USB 数据包中并将其发送到线路。也许您正在使用帧缓冲区控制台,如果您没有安装 GUI,这是与操作系统交互的默认方式。在这种情况下,内核必须将文本渲染到框架中并将其输出到显示器。
关键是接下来发生的任何事情都可能发生,而且具体是什么并不重要。因为您发送的 Hello World 消息只是您计算机上当前运行的数百万个系统调用和数千个程序中来自一个程序的一个系统调用。
结论
因此,当今硬件上的现代软件系统是如此复杂和错综复杂,以至于尝试完全理解计算机所做的一件小事确实没有意义。很明显,为了解释我所做的一切,我掩盖了很多内容。我没有详细介绍所有的边缘情况、附加信息以及计算机所做的其他事情。我没有解释内核是如何工作的。这些都是需要其他人解释的东西,或者需要您自己花时间了解的东西。
如果您真的从头到尾读完本文,那么恭喜您。很抱歉,结局可能没有你希望的那么令人满意。我很高兴有人发现这很有趣。我不太清楚为什么要写这篇文章,但现在已经是午夜了,所以我应该睡觉了。
感谢您的阅读。
嘿,那么 Hello World 程序实际上是如何工作的呢?
别问。