一个重新理解程序的角度 (enlightning)

程序 = 状态机 : 每个程序可以看做一个状态机

状态=M(内存) +R(寄存器)

可以用状态机和状态转移分析来分析程序的正确性

初始状态是程序启动时操作系统给安排的状态

状态转移=时钟驱动的指令执行

假设当前状态是 (M, R)

  • R[PC] 取出一条指令

  • 解析指令,取出必要的数据

  • 计算结果(可能具有非确定性,例如rdrand )

  • 更新得到(M', R')

img

构造一个最小的hello world

失败的尝试

first try

#include<stdio.h>
int main() {
printf("hello world\n");
}

编译hello world程序,可以看到编译出来的a.out文件是非常大的

jones@DESKTOP-60SP3JA:~/jyyos/lec2/try1$ gcc hello.c
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try1$ ./a.out
hello world
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try1$ ls -al
total 32
drwxr-xr-x 2 jones jones 4096 Oct 15 12:59 .
drwxr-xr-x 3 jones jones 4096 Oct 15 12:59 ..
-rwxr-xr-x 1 jones jones 16696 Oct 15 12:59 a.out
-rw-r--r-- 1 jones jones 59 Oct 15 12:59 hello.c

看a.out反汇编结果, 指令很多

objdump -d a.out | less

img

这不符合最小hello world的需求,我们决定放弃gcc hello.c的编译方式

直接用ld链接

gcc -c hello.c

得到hello.o文件

再链接hello.o

ld hello.o

img

链接失败,错误信息是不知道怎么链接函数库

使用空的main函数

好家伙,直接不要printf了,使用空的main函数,再编译

int main() {
return 1;
}

img

链接成功,只是报了一个找不到entry _start的warning

修改代码,将main()改成_start(), 就可以消除这个warning

int _start() {
return 1;
}

img

反汇编得到的指令非常少,确实满足了最小程序这个原则

img

然而,运行这个程序,段错误了

img

调试segmentation fault

我们需要一个工具帮助我们观察程序(状态机)的执行!

gdb

用gdb来调试一下段错误吧

  • gdb a.out 启动gdb调试
  • starti 转到第一条指令
(gdb) starti
Starting program: /home/jones/jyyos/lec2/try1/a.out

Program stopped.
0x0000000000401000 in _start ()
(gdb)

查看初始寄存器状态

(gdb) info registers
...
rsp 0x7fffffffe400 0x7fffffffe400
...
rip 0x401000 0x401000 <_start>
eflags 0x200 [ IF ]
...
(gdb)

可以用x/i打印$rip中的指令

(gdb) x/i $rip
=> 0x401000 <_start>: endbr64

查看当前内存分配情况

首先用info inferiors查看当前进程进程号

(gdb) info inferiors
Num Description Executable
* 1 process 5964 /home/jones/jyyos/lec2/try1/a.out

用linux的cat命令查看当前进程的内存分配情况

(gdb) !cat /proc/5964/maps
00400000-00401000 r--p 00000000 08:40 176949 /home/jones/jyyos/lec2/try1/a.out
00401000-00402000 r-xp 00001000 08:40 176949 /home/jones/jyyos/lec2/try1/a.out
00402000-00403000 r--p 00002000 08:40 176949 /home/jones/jyyos/lec2/try1/a.out
7ffff7ffa000-7ffff7ffe000 r--p 00000000 00:00 0 [vvar]
7ffff7ffe000-7ffff7fff000 r-xp 00000000 00:00 0 [vdso]
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
(gdb)

可以看到00400000-00401000是一段只读的内存

00401000-00402000是一段可读可执行的内存

# 访问没有分配的内存地址
(gdb) x 0x1234
0x1234: Cannot access memory at address 0x1234
# 访问在映射中的地址
(gdb) x 0x400000
0x400000: 0x7f
img

什么时候发生了seg fault

layout asm切换到汇编视图

img

si单步执行,发现在retq这里发生了seg fault

(gdb) si
0x0000000000000001 in ?? ()
Cannot access memory at address 0x1
(gdb)

retq这一步发生什么事了

retq这条汇编指令做了什么工作

$pc <- M[$rsp]
$rsp += 8

我们回到retq执行前一步, 看调用retq前状态机所处的状态

查看rsp寄存器的值

(gdb) x/x $rsp
0x7fffffffe400: 0x00000001

查看之前的内存映射,这个程序不能访问这个地址,因此访问M[$rsp]时就会失败

不想异常退出,那该怎么做

让操作系统参与进来

#include<sys/syscall.h>
#include<unistd.h>
int main() {
syscall(SYS_exit, 42);
}

编译运行成功,用echo $?查看返回值得到42,这是系统调用的参数

jones@DESKTOP-60SP3JA:~/jyyos/lec2/try2$ gcc hello.c
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try2$ ./a.out
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try2$ echo $?
42

再用gdb进行调试,运行到syscall系统调用函数之前

img

查看此时寄存器状态

(gdb) info registers
rax 0x3c 60
...
rdi 0x2a 42

执行这条syscall指令, 退出程序

(gdb) si
[Inferior 1 (process 21950) exited with code 052]
img

Minimal Hello World (汇编实现)

#include <sys/syscall.h>

.globl _start
_start:
movq $SYS_write, %rax # write(
movq $1, %rdi # fd=1,
movq $st, %rsi # buf=st,
movq $(ed - st), %rdx # count=ed-st
syscall # );

movq $SYS_exit, %rax # exit(
movq $1, %rdi # status=1
syscall # );

st:
.ascii "\033[01;31mHello, OS World\033[0m\n"
ed:

编译运行

[jones@DESKTOP-60SP3JA:~/jyyos/lec2/try3$ gcc -c minimal.S
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try3$ ls
minimal.S minimal.o
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try3$ ld minimal.o
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try3$ ./a.out
Hello, OS World

输出了一个红色的hello world

img

可以用gdb调试一下,流程和之前一致

操作系统中的程序

回到之前程序=状态机(M, R)的理解

程序中的指令大致可以分为两种

  • 内部指令: add, call, ret

    • 确定的: 完全根据当前(M, R)计算出一个新的(M, R)
    • 非确定的:rdrand
  • 外部指令(只有一条)

    • syscall: 交给操作系统,让操作系统决定下一个状态是什么
    • 除了可以观察syscall返回的结果,程序完全不知道syscall发生了什么

为什么要这么做?为什么要操作系统, 为什么程序有唯一的对os api的调用

操作系统实现了资源的同一管理。

  • 程序只能用操作系统允许的方式访问操作系统中的对象,从而实现操作系统的霸主地位。
  • 这是为管理多个状态机所必须的,不能打架,谁有权限就给谁

程序作为操作系统中的对象

可执行文件=字节序列

  • 可执行文件和文本文件(比如程序)没有任何区别
  • 操作系统提供API打开,读取,改写 (都需要响应的权限)

查看/bin/ls文件

jones@DESKTOP-60SP3JA:~/jyyos/lec2/try3$ file /bin/ls
/bin/ls: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=2f15ad836be3339dec0e2e6a3c637e08e48aacbd, for GNU/Linux 3.2.0, stripped
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try3$ xxd /bin/ls | less
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0300 3e00 0100 0000 d067 0000 0000 0000 ..>......g......
.....

一些真实的例子

一个普通的C程序执行的第一条指令在哪里

  • main的第一条指令? ×

  • libc的_start? ×

试试呗

(gdb) starti
Starting program: /tmp/a.out

Program stopped.
0x00007ffff7dd6090 in _start () from /lib64/ld-linux-x86-64.so.2
(gdb) bt
#0 0x00007ffff7dd6090 in _start () from /lib64/ld-linux-x86-64.so.2
#1 0x0000000000000001 in ?? ()

第一条指令是动态链接的加载器

jones@DESKTOP-60SP3JA:~/jyyos/lec2/try3$ readelf -a /bin/ls |less

img

  • ld-linux-x86-64.so加载了libc
  • 之后libc完成了自己的初始化

main()函数的开始/结束不是整个程序的开始/结束

#include <stdio.h>

__attribute__((constructor)) void hello() {
printf("Hello, World\n");
}

// See also: atexit(3)
__attribute__((destructor)) void goodbye() {
printf("Goodbye, Cruel OS World!\n");
}

int main() {
printf("Main\n");
}
jones@DESKTOP-60SP3JA:~/jyyos/lec2/try4$ ./a.out
Hello, World
Main
Goodbye, Cruel OS World!

main函数在执行之前,执行中,执行后,发生了哪些操作系统API调用

一个非常重要的工具: strace (system call trace)

输出程序运行时使用户的系统调用

jones@DESKTOP-60SP3JA:~/jyyos/lec2/try4$ strace ./a.out | less
execve("./a.out", ["./a.out"], 0x7ffd63bf5f30 /* 22 vars */) = 0
brk(NULL) = 0x55ddb7ce3000
arch_prctl(0x3001 /* ARCH_??? */, 0x7fff04c654a0) = -1 EINVAL (Invalid argument)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=44804, ...}) = 0
mmap(NULL, 44804, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f4b9010d000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360q\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
pread64(3, "\4\0\0\0\20\0\0\0\5\0\0\0GNU\0\2\0\0\300\4\0\0\0\3\0\0\0\0\0\0\0", 32, 848) = 32
pread64(3, "\4\0\0\0\24\0\0\0\3\0\0\0GNU\0\t\233\222%\274\260\320\31\331\326\10\204\276X>\263"..., 68, 880) = 68
fstat(3, {st_mode=S_IFREG|0755, st_size=2029224, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4b9010b000
...

本质上,所有的程序和hello world类似

  • 被操作系统加载
    • 通过另一个进程执行execve
  • 状态机执行(内部+唯一的外部指令syscall)
    • 进程管理: fork, execve, exit …
    • 文件/设备管理: open, close, read, write …
    • 存储管理: mmap, brk
  • 直到_exit(exit_group)退出

总结

程序=状态机(M, R)

操作系统=对象+API