前言

学习过程中发现自己对一些计算机系统的基础知识并不了解,目前暂时也没有精力去系统的学习,于是就将经常遇到的又不懂的知识归纳一下


正文

虚拟地址与物理地址之间的映射

先看一下进程虚拟地址空间的总体布局,以32位Linux系统为例:

基于上图的虚拟地址空间布局来简单说下ELF文件是怎样映射到进程虚拟地址空间的,ELF文件被组织成如下图左列出的一系列section,其中具有相同属性(R/W/E)的section再组成一个segment,以segment为单位映射到进程的虚拟地址空间,其中虚拟地址空间中的segment要做到页大小对齐,下图也一同简要展示了虚拟地址空间到物理地址空间的映射,通过MMU完成。

其它系统原理类似,都是将可执行程序组织成若干segment连同用到的动态库和kernel映射到进程的虚拟地址空间,主要差别在于不同segment映射的起始地址、大小不同等,比如32位Linux系统的Text segment起址是0x08048000,64位Linux系统的Text segment起址是0x00400000,再比如相对32位Linux系统的kernel space是1G,32位Windows的kernel space是2G等等。

main函数参数

学c语言时有没有学过忘了,反正我不会,了解一下

  • int argc:这个东西是所有参数的个数,包括文件名

  • char* argv[]:这个东西里面,argv[]是argc个参数,其中第0个参数即argv[0]是程序的全名,后面跟着的就是用户输入的参数了

  • char* envp[]:这个东西用来取得系统的环境变量,envp保存了系统所有的环境变量路径

从源代码到可执行文件

过程可分为4个步骤:预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。

以hello word 为例

1
2
3
4
5
#include <stdio.h>
main()
{
printf("hello, world\n");
}

预编译

1
gcc -E hello.c -o hello.i
1
2
3
4
5
6
7
8
9
# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
......
extern int printf (const char *__restrict __format, ...);
......
main() {
printf("hello, world\n");
}

预编译过程主要处理源代码中以 “#” 开始的预编译指令:

  • 将所有的 “#define” 删除,并且展开所有的宏定义。
  • 处理所有条件预编译指令,如 “#if”、“#ifdef”、“#elif”、“#else”、“#endif”。
  • 处理 “#include” 预编译指令,将被包含的文件插入到该预编译指令的位置。注意,该过程递归执行。
  • 删除所有注释。
  • 添加行号和文件名标号。
  • 保留所有的 #pragma 编译器指令。

编译

1
gcc -S hello.c -o hello.s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
        .file   "hello.c"
.section .rodata
.LC0:
.string "hello, world"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rdi
call puts@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 7.2.0"
.section .note.GNU-stack,"",@progbits

编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成相应的汇编代码文件。

汇编

1
2
3
$ gcc -c hello.s -o hello.o
或者
$gcc -c hello.c -o hello.o
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ objdump -sd hello.o

hello.o: file format elf64-x86-64

Contents of section .text:
0000 554889e5 488d3d00 000000e8 00000000 UH..H.=.........
0010 b8000000 005dc3 .....].
Contents of section .rodata:
0000 68656c6c 6f2c2077 6f726c64 00 hello, world.
Contents of section .comment:
0000 00474343 3a202847 4e552920 372e322e .GCC: (GNU) 7.2.
0010 3000 0.
Contents of section .eh_frame:
0000 14000000 00000000 017a5200 01781001 .........zR..x..
0010 1b0c0708 90010000 1c000000 1c000000 ................
0020 00000000 17000000 00410e10 8602430d .........A....C.
0030 06520c07 08000000 .R......

Disassembly of section .text:

0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # b <main+0xb>
b: e8 00 00 00 00 callq 10 <main+0x10>
10: b8 00 00 00 00 mov $0x0,%eax
15: 5d pop %rbp
16: c3 retq

汇编器将汇编代码转变成机器可以执行的指令。

链接

1
gcc hello.o -o hello
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ objdump -d -j .text hello
......
000000000000064a <main>:
64a: 55 push %rbp
64b: 48 89 e5 mov %rsp,%rbp
64e: 48 8d 3d 9f 00 00 00 lea 0x9f(%rip),%rdi # 6f4 <_IO_stdin_used+0x4>
655: e8 d6 fe ff ff callq 530 <puts@plt>
65a: b8 00 00 00 00 mov $0x0,%eax
65f: 5d pop %rbp
660: c3 retq
661: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
668: 00 00 00
66b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
......

目标文件需要链接一大堆文件才能得到最终的可执行文件(上面只展示了链接后的 main 函数,可以和 hello.o 中的 main 函数作对比)。链接过程主要包括地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution)和重定向(Relocation)等。

linux程序执行流程

ASLR与PIE

总是混淆分不清,整理一下

作用位置 归属 作用时间
ASLR 0:不开启
1:栈基地址(stack)、共享库(.so\libraries)、mmap 基地址
2:在 1 基础上,增加随机化堆基地址(chunk)
系统功能 作用于程序(ELF)装入内存运行时
PIE 代码段( .text )、初始化数据段( .data )、未初始化数据段( .bss ) 编译器功能 作用于程序(ELF)编译过程中
1
2
修改ASLR设置
echo 0/1/2 > /proc/sys/kernel/randomize_va_space

XMM寄存器

基础

现代处理器还有一些扩展,这些扩展体现在电路上,指令集上,有时候也会扩展一些很有用的寄存器。

比较著名的扩展叫作 SSE (Streaming SIMD Extensions),该扩展加入了新的 xmm 寄存器集合:

xmm0,xmm1,…,xmm15。共16个寄存器,这些寄存器固定为 128 位宽,常用于两种任务:

  • 浮点数运算;
  • SIMD 指令集(这种指令一条指令可以操作多条数据)

每个XMM寄存器都可以存储128位(16字节)的数据,并且可以用于执行一次性并行处理多个数据的操作,如浮点运算、图像处理和向量运算等。这使得XMM寄存器在执行高性能计算和多媒体应用程序时非常有用。

内存对齐问题

先看操作xmm寄存器的四个指令

1
2
3
4
5
6
7
movaps和movups之间的区别在于对内存对齐的要求。movaps要求数据在内存中按照128位对齐,而movups可以处理未对齐的数据。

movups:将128位数据从一个XMM寄存器或内存位置复制到另一个XMM寄存器或内存位置。与movaps不同,movups不要求数据在内存中按照128位对齐。

movdqa:将128位数据从一个XMM寄存器或内存位置复制到另一个XMM寄存器或内存位置。与movaps类似,movdqa要求数据在内存中按照128位对齐。

movdqu:类似于movups,它将128位数据从一个XMM寄存器或内存位置复制到另一个XMM寄存器或内存位置,但是对内存对齐没有特殊要求。它可以处理未对齐的数据,并且在某些情况下,可能会导致性能下降。

结论:movaps和movdqa要求内存对齐,而movups和movdqu不要求

大多数 SSE 指令都需要内存操作数适当地进行对齐。上面说到的未对齐版本的指令和对齐版本的指令在助记符上就有差别,而且因为内存未对齐的关系,性能也会受影响。由于 SSE 指令经常被用在性能敏感的场合,所以始终使用操作数内存对齐版本的指令是明智之举。

CISC和RISC

处理器有一种按照其指令集进行分类的方式。当设计一个处理器时有两个极端。

  • 设计出各种特化指令,高级指令。这种架构叫作 CISC (Complete Instruction Set Computer) 架构。
  • 只使用一些基本指令,完成的架构叫 RISC (Reduced Instruction Set Computer) 架构。
1
2
3
4
指令集复杂:CISC架构的指令集包含大量的复杂指令,可以执行更高级的操作,如内存访问、字符串处理、浮点运算等。这些指令通常具有多个操作数和复杂的寻址模式。
指令多样性:CISC架构的指令集包含各种不同的指令,每个指令可以执行多个操作,甚至一个指令可以完成一系列操作。这使得编程更灵活,但也增加了硬件的复杂性。
存储器访问:CISC架构通常允许直接访问内存,即指令可以直接操作内存中的数据,而不需要将数据加载到寄存器中。
指令长度不统一:CISC指令长度可以不同,从几个字节到几十个字节不等,这使得指令解码复杂。
1
2
3
4
简化指令集:RISC架构的指令集相对简化,指令数量较少且固定,每个指令执行的操作也更加简单和基本,如算术运算、逻辑运算等。
指令统一性:RISC架构的指令长度一般是固定的,通常为两个字(32位)或一个字(16位),这简化了指令解码和处理器设计。
寄存器优先:RISC架构鼓励使用寄存器操作,大多数操作都在寄存器上进行,减少了对内存的直接访问。
流水线执行:RISC架构更注重流水线执行,指令之间的依赖关系较少,可以并行执行,提高了处理器的性能。
对比 CISC(X86) RISC(ARM,MIPS,etc)
访存模式 多种寻址模式 load/store
指令宽度 变长 定长
操作数来源 内存或寄存器 寄存器
IO通信 占用的IO指令和IO地址空间 内存映射
访存对齐 不需要 需要
数据类型 多而复杂 少而简洁
寄存器堆 相对更小 相对更大
设计原则 功能多样的复杂指令集 功能完备的精简指令集

浮点数在内存中的存储

在计算机中,浮点数表示方式采用了IEEE 754标准。IEEE 754定义了浮点数的二进制表示规范,它规定了浮点数的位数、指数位数、小数位数等。

符号位 指数位 小数位
float 1位 11位(偏移1023) 52位
double 1位 8位(偏移127) 23位

具体的存储方式为:

  1. 符号位:用于表示浮点数的正负号,0表示正数,1表示负数。
  2. 指数位:用于表示浮点数的指数部分。采用”偏移量表示法”,即指数的实际值等于存储的值减去一个偏移量,这样可以使指数既有正数又有负数的表示范围。
  3. 小数位:用于表示浮点数的小数部分。

具体的转换过程如下:

  1. 将浮点数转换为二进制科学计数法:将浮点数表示为M乘以2的E次方的形式。其中M是一个大于等于1且小于2的小数(隐藏了最高位的1),E是整数。
  2. 根据科学计数法,将M和E转换为二进制表示。
  3. 将符号位、指数位、和小数位组合在一起形成64位二进制数,即双精度浮点数的内存表示。

举个例子,我们将3.14转换成双精度浮点数(double)的二进制表示:

  1. 3.14的二进制科学计数法为1.570*2^1,其中1.570是小数部分,2^1是2的1次方,即2。
  2. 1.570的二进制表示是1.1010001001100110011001100110011001100110011001101
  3. 2的二进制表示是10

现在将符号位、指数位、和小数位组合在一起:

1
2
3
符号位: 0 (表示正数)
指数位: 1023 + 1 = 1024,对应的二进制表示是:`10000000000`
小数位: `1010001001100110011001100110011001100110011001101`

将它们放在一起:

1
0 10000000000 1010001001100110011001100110011001100110011001101

NaN:

在IEEE 754浮点数标准中,NaN(Not a Number)是一种特殊的浮点数表示,用于表示非法的或未定义的操作的结果。NaN不是一个具体的数字,而是一种特殊的标记。

01111111111 1111 1111 1111 1111 1111 1111 1111 1111

满足

Comparison NaN ≥ x NaN ≤ x NaN > x NaN < x NaN = x NaN ≠ x
Result False False False False False True

且NaN!=NaN是成立的

小数转二进制

例如,我们要将十进制数0.625转换为二进制表示:

  1. 0.625的整数部分为0,保留小数部分。
  2. 将0.625乘以2,得到1.25。整数部分为1,保留小数部分0.25。
  3. 再将0.25乘以2,得到0.5。整数部分为0,保留小数部分0.5。
  4. 继续将0.5乘以2,得到1.0。整数部分为1,没有小数部分,结束。

将每次得到的整数部分依次排列起来,就得到了0.625的二进制表示:0.101。

对于无限不循环小数(如π、e等),在计算机中是无法完全表示的,因为计算机的内存是有限的。因此,对于这些无限不循环小数,计算机只能使用有限的位数来表示,导致了一定的精度损失。

四大常见架构

  1. x86 架构
    • 最常见的处理器架构之一,由英特尔(Intel)和 AMD 公司开发和推广。
    • 主要用于个人电脑和服务器领域。
    • 典型的操作系统如Windows和Linux都支持x86架构。
    • x86处理器通常采用复杂指令集计算机(CISC)设计,具有广泛的软件兼容性。
  2. ARM 架构
    • 常见于移动设备、嵌入式系统和物联网设备中。
    • 低功耗、高性能和节能的特性使其成为移动计算领域的主流。
    • ARM处理器通常采用精简指令集计算机(RISC)设计,具有出色的能效和性能平衡。
  3. MIPS 架构

    • 最初用于工作站和嵌入式系统。
    • 具有良好的性能和可扩展性,适用于一些特定应用。
    • 在某些嵌入式领域仍然有一定的存在。
  4. RISC-V

    • RISC-V是一种开放标准的指令集架构(ISA),任何人都可以免费使用、实现和定制它,而不必支付专利费用。这使得RISC-V在开源和学术界广泛流行。

    • 与传统的CISC架构(如x86)不同,RISC-V采用了精简指令集计算机(RISC)设计原则,使指令集更简单和统一,有助于提高性能和降低功耗。

    • RISC-V的设计非常模块化,可以根据需求自定义指令集,这使得它非常适合各种应用,从嵌入式系统到高性能计算。

    • RISC-V可用于各种应用领域,包括嵌入式系统、物联网设备、移动设备、服务器、超级计算机等。

  5. Power 架构

    • 最初由IBM开发,用于高性能计算和服务器领域。
    • 具有强大的多核和多线程性能,广泛应用于超级计算机和高性能计算集群。

数据编码

linux-proc目录

在Linux系统中,/proc目录是一个虚拟文件系统,用于提供有关当前运行中的内核和系统状态的信息。它不包含实际的文件,而是包含一组伪文件和子目录,这些文件和目录提供了有关系统内核、进程、硬件和其他系统信息的实时视图。

以下是/proc目录的一些常见用途和子目录:

  1. /proc/cpuinfo: 包含有关CPU的信息,如制造商、型号、时钟频率等。
  2. /proc/meminfo: 提供系统内存使用的信息,包括总内存、可用内存、缓存等。
  3. /proc/sys: 包含用于配置内核参数的文件。可以通过这些文件来动态更改内核的某些行为。
  4. /proc/: 每个正在运行的进程都有一个以其进程ID(PID)命名的子目录,其中包含有关该进程的信息,如命令行参数、状态、打开的文件描述符等。
  5. /proc/net: 包含有关网络协议、接口和连接的信息。
  6. /proc/loadavg: 提供系统的负载平均值,以及最近1分钟、5分钟和15分钟的负载平均值。
  7. /proc/filesystems: 列出支持的文件系统类型。
  8. /proc/interrupts: 显示当前系统上的中断分配情况,可以用于监视硬件中断的使用情况。
  9. /proc/mounts: 列出当前已挂载的文件系统。
  10. /proc/sys/kernel: 包含与内核相关的参数,如主机名、域名、内核版本等。
  11. /proc/sys/fs: 包含与文件系统相关的参数,如文件句柄限制等。

这些信息对于系统管理、性能调优和故障排除非常有用,管理员和开发人员可以通过读取/proc目录中的文件来了解系统的运行状况和性能指标。需要注意的是,/proc目录中的信息是动态的,可以在运行时获取,因此可以用于实时监控系统状态。

其中

进程的内存信息都会存储于/proc/pid/maps中,

如果不知道pid,这里pid可以使用self来代替,就能获取本进程libc的地址了。

函数指针与函数变量

函数变量:

  1. 函数变量的定义和使用:
    • 函数变量实际上是一个函数名,可以用来表示一个函数。
    • 当你使用函数变量时,实际上是在引用函数本身,而不是函数的地址。
    • 函数变量在被调用时,会直接执行函数体中的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

void myFunction(int x) {
printf("Value passed: %d\n", x);
}

int main() {
// 函数变量的使用
void (*functionVar)(int) = myFunction;

// 直接调用函数变量
functionVar(42);

return 0;
}

函数指针:

  1. 函数指针的定义和使用:
    • 函数指针是一个指向函数的指针,它存储函数的地址而不是函数本身。
    • 使用函数指针时,需要通过解引用来调用函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>


// 函数
void myFunction(int x) {
printf("Value passed: %d\n", x);
}

int main() {
// 函数指针的定义
void (*functionPtr)(int);
// 函数指针的初始化
functionPtr = myFunction;

// 通过函数指针调用函数
(*functionPtr)(42);

return 0;
}

主要异同:

  1. 声明方式:
    • 可以看到二者的声明方式是完全一致的,而到底是将其作为函数指针还是函数变量也是由用户决定的,毕竟二者在汇编层面都是一致的
  2. 调用方式:
    • 函数变量直接使用变量名调用函数。
    • 函数指针需要通过解引用来调用函数。
  3. 用途:
    • 函数变量主要用于简化代码,使得可以通过一个变量名引用一个函数。
    • 函数指针通常用于传递函数作为参数给其他函数,以及实现一些高级的动态调用机制。

总的来说,函数变量更像是一个别名,而函数指针则更侧重于存储和传递函数的地址,提供了更灵活的方式来处理函数。

编译保护选项

安全技术 完全开启 部分开启 关闭
Canary -fstack-protector-all -fstack-protector -fno-stack-protector
NX -z noexecstack -z execstack
PIE -pie -no-pie
RELRO -z now -z lazy -z norelro