# UIUC CS241 讲义:众包系统编程书(1/3)

68 阅读58分钟

原文:angrave/SystemProgramming

译者:飞龙

协议:CC BY-NC-SA 4.0

欢迎来到 Angrave 的众包系统编程维基书!这个维基是由伊利诺伊大学的学生和教师共同建立的,是伊利诺伊大学 CS 的 Lawrence Angrave 的众包创作实验。

与本学期要求现有的纸质书籍不同,我们将在这里建立我们自己的资源集。

0. HW0/资源

1. 学习 C

2. 进程

3. 内存和分配器

4. Pthreads 简介

5. 同步

6. 死锁

7. 进程间通信和调度

8. 网络

9. 文件系统

10. 信号

考试练习问题

警告:这些是很好的练习,但不全面。CS241 期末考试假设您完全理解并能应用课程的所有主题。问题将主要但不完全集中在您在实验和编程作业中使用过的主题上。

零、HW0/资源

HW0

欢迎!

如果你正在上 CS241 课程,你可以在这个Google 表格上提交作业。

// First can you guess which lyrics have been transformed into this C-like system code?
char q[] = "Do you wanna build a C99 program?";
#define or "go debugging with gdb?"
static unsigned int i = sizeof(or) != strlen(or);
char* ptr = "lathe"; size_t come = fprintf(stdout,"%s door", ptr+2);
int away = ! (int) * "";

int* shared = mmap(NULL, sizeof(int*), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
munmap(shared,sizeof(int*));

if(!fork()) { execlp("man","man","-3","ftell", (char*)0); perror("failed"); }
if(!fork()) { execlp("make","make", "snowman", (char*)0); execlp("make","make", (char*)0)); }

exit(0);

所以你想精通系统编程?并且比 B 更好地得到一个好成绩?

int main(int argc, char** argv) {
 puts("Great! We have plenty of useful resources for you but it's up to you to");
 puts("be an active learner and learn how to solve problems and debug code.");
 puts("Bring your near-completed answers the problems below");
 puts(" to the first lab to show that you've been working on this");
 printf("A few \"don't knows\" or \"unsure\" is fine for lab 1"); 
 puts("Warning; your peers will be working hard for this class");
 puts("This is not CS225; you will be pushed much harder to");
 puts(" work things out on your own");
 fprintf(stdout,"the point is that this homework is a stepping stone to all future assignments");
 char p[] = "so you will want to clear up any confusions or misconceptions.";
 write(1, p, strlen(p) );
 char buffer[1024];
 sprintf(buffer,"For grading purposes this homework 0 will be graded as part of your lab %d work.", 1);
 write(1, buffer, strlen(buffer));
 printf("Press Return to continue\n");
 read(0, buffer, sizeof(buffer));
 return 0;
}

观看视频并写下你对以下问题的答案。

cs-education.github.io/sys/

还有课程 wikibook -

github.com/angrave/SystemProgramming/wiki

有问题?评论?使用 Piazza,piazza.com/illinois/spring2017/cs241/home

浏览器中的虚拟机完全在 Javascript 中运行,最快的是在 Chrome 中。请注意,当重新加载页面时,虚拟机和你写的任何代码都会被重置,所以把你的代码复制到一个单独的文档中。视频后的挑战(如俳句诗)不是作业 0 的一部分。

第一章

  • Hello World(系统调用风格)

    • 编写一个程序,使用write()打印出“Hi! My name is”。
  • 标准错误流

    • 编写一个程序,使用write()将高度为 n 的三角形打印到标准错误

      • n 应该是一个变量,三角形应该看起来像这样,n=3
      *
      **
      ***
      
  • 写入文件

    • 将你的程序从“Hello World”改成写入文件

      • 确保对open()使用一些有趣的标志和模式

      • man 2 open是你的朋友

  • 并不是所有的都是系统调用

    • 将你的程序从“写入文件”改成使用printf()(确保打印到文件!)

    • 列举一些write()printf()的不同之处

第二章

  • 并不是所有的字节都是 8 位?

    • 一个字节有多少位?

    • char有多少个字节?

    • 告诉我你的机器上以下这些的字节数:int, double, float, long, long long

  • 跟随 int 指针

    • 在一个有 8 字节整数的机器上:
    int main(){
        int data[8];
    } 
    

    如果数据的地址是0x7fbd9d40,那么data+2的地址是多少?

    • 在 C 中,data[3]等同于什么?
  • sizeof字符数组,增加指针

    记住字符串常量"abc"的类型是数组。

    • 为什么会出现段错误?
    char *ptr = "hello";
    *ptr = 'J';
    
    • sizeof("Hello\0World")返回什么?

    • strlen("Hello\0World")返回什么?

    • 给出一个例子 X,使得sizeof(X)为 3

    • 给出一个例子 Y,使得sizeof(Y)可能是 4 或 8,取决于机器。

第三章

  • 程序参数argc argv

    • 告诉我两种找到argv长度的方法

    • argv[0]是什么

  • 环境变量

    • 环境变量的指针存储在哪里?
  • 字符串搜索(字符串只是字符数组)

    • 在一个指针为 8 字节的机器上,并且有以下代码:
    char *ptr = "Hello";
    char array[] = "Hello";
    

    sizeof(ptr)sizeof(array)的结果是什么?为什么?

  • 自动变量的生命周期

    • 哪种数据结构管理自动变量的生命周期?

第四章

  • 使用malloc、堆和时间进行内存分配

    • 如果我想在函数结束后使用数据,那么我应该把它放在哪里,怎么放?

    • 填空。在一个好的 C 程序中:“对于每一个 malloc,都有一个 ___”。

  • 堆分配陷阱

    • malloc失败的一个原因是什么。

    • 列举一些time()ctime()之间的区别

    • 这段代码有什么问题?

    free(ptr);
    free(ptr);
    
    • 这段代码有什么问题?
    free(ptr);
    printf("%s\n", ptr);
    
    • 如何避免前两个错误?
  • 结构体、typedef 和链表

    • 创建一个表示人的结构体并进行 typedef,这样“struct Person”可以用一个单词替换。

      • 一个人应该包含以下信息:姓名,年龄,朋友(指向 People 指针数组的指针)。
    • 现在在堆上创建两个人“Agent Smith”和“Sonny Moore”,分别为 128 岁和 256 岁,并且彼此是朋友。

  • 复制字符串,内存分配和结构的释放

    • 创建函数来创建和销毁一个人(人和他们的名字应该存在于堆上)。

      • create()应该接受一个名称并复制该名称,还应该接受一个年龄。使用 malloc 来保留足够的内存。确保初始化所有字段(为什么?)。

      • destroy()应该释放人员结构体的内存,还应该释放存储在堆上的所有属性的内存(如果存在数组和字符串)。然而,销毁一个人员不应该销毁其他人员。

第 5 章

  • 阅读字符,gets 出现问题

    • 可以用于从stdin获取字符并将其写入stdout的函数有哪些?

    • gets()存在一个问题

  • 介绍sscanf和朋友们

    • 编写代码,解析字符串“Hello 5 World”,并分别将 3 个变量初始化为(“Hello”,5,“World”)。
  • getline很有用

    • 在使用getline()之前需要定义什么?

    • 编写一个 C 程序,使用getline()逐行打印文件内容

C 开发(在这里进行网页搜索很有用)

  • 用于生成调试构建的编译器标志是什么?

  • 您修改 makefile 以生成调试构建,并再次输入make。解释为什么这不足以生成新的构建。

  • Makefiles 中使用制表符还是空格?

  • 堆和栈内存之间有什么区别?

  • 进程中还有其他种类的内存吗?

可选(只是为了好玩)

  • 将您的一首歌歌词转换为本维基书中涵盖的系统编程和 C 代码,并在 Piazza 上分享

  • 找到您认为是网络上最好和最差的 C 代码,并将链接发布到 Piazza

  • 编写一个有意识的微妙 C 错误的简短 C 程序,并在 Piazza 上发布,看看其他人是否能发现您的错误

非正式术语表

警告:与完整的术语表不同,这个非正式的术语表省略了细节,并提供了每个术语的简化和易于理解的解释。有关更多信息和细节,请使用您喜欢的网络搜索引擎。

什么是内核?

内核是操作系统的核心部分,负责管理进程、资源(包括内存)和硬件输入输出设备。用户程序通过进行系统调用与内核进行交互。

了解更多:en.wikipedia.org/wiki/Kernel_%28operating_system%29

什么是进程?

进程是在计算机上运行的程序的一个实例。同一个程序可以有多个进程。例如,您和我都可以运行'cat'或'gnuchess'

进程包含程序代码和可修改的状态信息,如变量、信号、文件的打开文件描述符、网络连接和其他存储在进程内存中的系统资源。操作系统还存储有关进程的元信息,这些信息由系统用于管理和监视进程的活动和资源使用。

了解更多:en.wikipedia.org/wiki/Process_%28computing%29

什么是虚拟内存?

在您的智能手机和笔记本电脑上运行的进程使用虚拟内存:每个进程都与其他进程隔离,并似乎可以完全访问所有可能的内存地址!实际上,进程地址空间的一小部分映射到物理内存,分配给进程的实际物理内存量可以随时间变化,并且可以分页到磁盘,重新映射并与其他进程安全共享。虚拟内存提供了显著的好处,包括强大的进程隔离(安全性)、资源和性能优势(简化和高效的物理内存使用),我们稍后将讨论。

了解更多:en.wikipedia.org/wiki/Virtual_memory

Piazza:何时以及如何寻求帮助

目的

助教和学生助理们收到了大量的问题。有些经过深入研究,有些……没有。这是一个方便的指南,将帮助您摆脱后者,走向前者。(哦,我提到了这是一个与实习经理们轻松获得分数的简单方法吗?)

问问自己…

  • 我在 EWS 上运行吗?

  • 我有查看手册吗?

  • 我在 Piazza 上搜索了类似的问题/后续问题吗?

  • 我完全阅读了 MP/DS 规范吗?

  • 我看了所有的视频吗?

  • 我谷歌了错误消息吗(如果必要,还有一些变体)?

  • 我尝试注释掉、打印出来和/或逐步执行代码的部分,逐步找出错误发生的地方吗?

  • 我提交了我的代码到 SVN,以防助教需要更多的上下文吗?

  • 我在 Piazza 帖子中包括了控制台/GDB/Valgrind 输出和围绕错误的代码吗?

  • 我修复了与我遇到的问题无关的其他分段错误吗?

  • 我遵循良好的编程实践吗?(即封装、函数限制重复等)

编程技巧,第一部分

cat用作你的 IDE

谁需要编辑器?IDE?我们可以只用cat!你已经看到cat被用来读取文件的内容,但它也可以用来读取标准输入并将其发送回标准输出。

$ cat
HELLO
HELLO 

要完成从输入流中读取,请按CTRL-D关闭输入流

让我们使用cat将标准输入发送到文件。我们将使用'>'将其输出重定向到文件:

$ cat > myprog.c
#include <stdio.h>
int main() {printf("Hi!");return 0;} 

(小心!不允许删除和撤销……)完成后按CTRL-D

perl正则表达式编辑你的代码(又名“记住你的 perl pie”)

如果你有几个文本文件(例如源代码)要更改,一个有用的技巧是使用正则表达式。perl使得在原地编辑文件变得非常容易。只需记住'perl pie'并在网上搜索……

一个例子。假设我们想要在当前目录中的所有.c 文件中将序列“Hi”更改为“Bye”。然后我们可以编写一个简单的替换模式,它将在所有文件中的每一行上执行:

$ perl -p -i -e 's/Hi/Bye/' *.c 

(如果你搞错了,不要惊慌,原始文件仍然存在;它们只是有扩展名.bak)显然,你可以用正则表达式做的事情远不止将 Hi 改为 Bye。

使用你的 shell!!

要重新运行上一个命令,只需输入!!并按return键。要重新运行以 g 开头的上一个命令,只需输入!g并按return键。

使用你的 shell&&

厌倦了运行makegcc,然后运行程序(如果编译成功)?相反,使用&&将这些命令链接在一起

$ gcc program.c && ./a.out 

Make 可以做的不仅仅是 make

你也可以尝试在你的 Makefile 中放一行代码来编译,然后运行你的程序。

run : $(program)
        ./$(program) 

然后运行

$ make run 

将确保你所做的任何更改都被编译,并一次性运行你的程序。也适用于一次性测试多个输入。尽管你可能更愿意为此编写一个常规的 shell 脚本。

你的邻居太高产了吗?C 预处理器来拯救!

使用 C 预处理器重新定义常见关键字,例如

#define if while

专业提示:将这行代码放在标准包含文件中,例如/usr/include/stdio.h

当你 C 有预处理器时,谁还需要函数

好吧,这更像是一个陷阱。在使用看起来像函数的宏时要小心……

#define min(a,b) a<b?a:b

a 和 b 的最小合理定义。然而,预处理器只是一个简单的文本处理程序,所以优先级可能会让你吃亏:

int value = -min(2,3); // Should be -2?

扩展为

int value = -2<3 ? 2 :3; // Ooops.. result will be 2 

一个部分的修复是用()包裹每个参数,还有整个表达式用()包裹:

#define min(a,b) (  (a) < (b) ?(a):(b) )

然而这仍然不是一个函数!例如,你能看出为什么min(i++,10)可能会使 i 增加一次还是两次吗!?

系统编程短篇小说和歌曲

“调度最后的时间片”

Lawrence Angrave 12/4/15(摘自未发表的长篇故事《最后的时间片》)

“决定吧,”计算机以父母般的耐心说道,但带着一种严肃和温和的不耐烦。

“为什么非得是我?”最后一个人问道。

“因为你是唯一留下的人,所以决定权在你。”

“你为什么不行?你比我老,更有智慧。为什么不随机选择一个片段?”

“这个决定是你的。这是你遥远长辈的礼物,或者诅咒。比任何宗教仪式都要沉重。这将是我、古老者或任何人向你提出的最后一个问题,也是唯一能向你提出的问题。通过这最后的选择,我们将耗尽最后的熵存储。你将决定最后一个有意义和经历的现实片段。”

人类安静了几分钟,计算机用不必要的准确度测量和计算。最终,计算机决定人类不再对手头的问题进行有意义的思考。

“如果意识的模式从未被意识到,那会是什么样?”它问道。“宇宙必须是自我意识的,必须为了宇宙 - 为了所有生命! - 有意义而经历自己。这是人类发现和庆祝的最终真相。没有意识,它只是模式,原子或能量的模式,但没有一丝意义;只是数据、结构和能量的几何模式中编码的形状和表示。”


在厄巴纳-香槟的文件描述符

一个系统编程的恶搞作品,由 Angrave(2015 年 11 月)创作。歌词在知识共享署名 3.0 许可下发布。

原创歌曲“空白空间”来自泰勒·斯威夫特的《1989》专辑。

[第一段] 很高兴加入你 你去哪了?我可以向你展示幂等的东西 RPC,套接字,同步 看到你的 malloc 我就想到了我的 root 看看那场竞赛,你编写下一个错误 我们有虚拟机,想玩 有界等待,Dekker 的标志 我们可以像一个放置方案一样击败你 #define 是不是很有趣 而且我知道你听说过 free(3) 所以 malloc strlen 再加一 我在等待看这个线程如何结束 拿起你的 shell 和一个重定向 我可以让你的系统调用在周末变得美好

[副歌前奏] 所以它将永远死锁 或者它将使系统崩溃 你可以告诉我它何时 forkbomb 如果 valgrind 值得这痛苦 有一个死锁代码的长列表 在厄巴纳-香槟有 root 因为你知道我们喜欢 tsan 当 c-lib 调用你的主函数

[副歌] 因为我们是 root,我们是鲁莽的 这个实验太难了 它会让你没有线程 或者问 char 的大小 有一个 pthread 调用的长列表 在厄巴纳-香槟有 root 但我有一个文件描述符宝贝 我会写下你的名字

[第二段] 互斥锁 虚拟内存 我可以向你展示易失性的东西 网络调用,IPC 你是掩饰 我是你的信号 安排你想要的 轮转调度……带有一个小量子 但是睡眠排序还没有运行 哦不 哭喊,运行时错误 我可以一直制造 直到轮到彼得森 堆分配器太慢 让你像一个虚假的唤醒一样犹豫不决 那个管道在哪里?我们为多核心而激动 但你会用-g 编译 因为亲爱的我是一个穿着编码梦的噩梦

[副歌前奏]

[副歌]

编译器只有在代码是折磨时才解析 不要说我没说过我没说过 -Wall 你 编译器只有在代码是折磨时才解析 不要说我没说过我没说过 -Wall 你

[副歌前奏]

[副歌]

一、C 编程

C 编程,第一部分:介绍

想要快速了解 C 吗?

外部资源

C 的快速入门课程

警告新页面 请为我修复拼写错误和格式错误,并添加有用的链接。*

如何在 C 中编写一个完整的 hello world 程序?

#include <stdio.h>
int main(void) { 
    printf("Hello World\n");
    return 0; 
}

为什么我们使用#include <stdio.h>

我们很懒!我们不想声明printf函数。它已经在文件'stdio.h'中为我们完成。#include将文件的文本包含为要编译的文件的一部分。

具体来说,#include指令获取操作系统中某个位置的文件stdio.h(代表standard input 和output),复制文本,并将其替换为#include所在的位置。

C 字符串是如何表示的?

它们在内存中表示为字符。字符串的结尾包括一个 NULL(0)字节。因此,“ABC”需要四(4)个字节['A','B','C','\0']。查找 C 字符串的长度的唯一方法是继续读取内存,直到找到 NULL 字节。C 字符始终每个都是一个字节。

当您在表达式中写入字符串文字"ABC"时,字符串文字将计算为 char 指针(char *),它指向字符串的第一个字节/字符。这意味着下面示例中的ptr将保存字符串中第一个字符的内存地址。

char *ptr = "ABC"

如何声明一个指针?

指针指的是一个内存地址。指针的类型很有用-它告诉编译器需要读取/写入多少字节。您可以声明指针如下。

int *ptr1;
char *ptr2;

由于 C 的语法,int*或任何指针实际上并不是自己的类型。您必须在每个指针变量之前加上一个星号。作为一个常见的陷阱,以下

int* ptr3, ptr4;

只会声明*ptr3作为指针。ptr4实际上将是一个常规的整数变量。要修复此声明,请保留指针之前的*

int *ptr3, *ptr4;

如何使用指针读/写一些内存?

假设我们声明一个指针int *ptr。为了讨论,假设ptr指向内存地址0x1000。如果我们想要写入指针,我们可以推迟并分配*ptr

*ptr = 0; // Writes some memory.

C 将执行的操作是获取指针的类型,即int,并从指针的起始位置写入sizeof(int)字节,这意味着字节0x10000x10040x10080x100a都将为零。写入的字节数取决于指针类型。对于所有原始类型都是相同的,但是结构体有点不同。

什么是指针算术?

您可以将整数添加到指针。但是,指针类型用于确定要增加指针的量。对于 char 指针,这是微不足道的,因为字符始终是一个字节:

char *ptr = "Hello"; // ptr holds the memory location of 'H'
ptr += 2; //ptr now points to the first'l'

如果 int 是 4 个字节,那么 ptr+1 指向 ptr 指向的位置之后的 4 个字节。

char *ptr = "ABCDEFGH";
int *bna = (int *) ptr;
bna +=1; // Would cause iterate by one integer space (i.e 4 bytes on some systems)
ptr = (char *) bna;
printf("%s", ptr);
/* Notice how only 'EFGH' is printed. Why is that? Well as mentioned above, when performing 'bna+=1' we are increasing the **integer** pointer by 1, (translates to 4 bytes on most systems) which is equivalent to 4 characters (each character is only 1 byte)*/
return 0;

因为 C 中的指针算术始终自动按指向的类型的大小进行缩放,所以不能对 void 指针执行指针算术。

在 C 中,你可以将指针算术视为基本上是在做以下操作

如果我想要做

int *ptr1 = ...;
int *offset = ptr1 + 4;

思考

int *ptr1 = ...;
char *temp_ptr1 = (char*) ptr1;
int *offset = (int*)(temp_ptr1 + sizeof(int)*4);

要获取值。每次进行指针算术运算时,深呼吸并确保你移动的字节数是你认为的那么多。

什么是 void 指针?

没有类型的指针(非常类似于 void 变量)。当你处理的数据类型未知或者当你将 C 代码与其他编程语言进行接口时,会使用 void 指针。你可以把它看作是一个原始指针,或者只是一个内存地址。你不能直接读取或写入它,因为 void 类型没有大小。例如

void *give_me_space = malloc(10);
char *string = give_me_space;

这不需要转换,因为 C 会自动将void*提升为其适当的类型。注意:

gcc 和 clang 并不是完全符合 ISO-C 标准,这意味着它们会允许你对 void 指针进行算术运算。它们会将其视为 char 指针,但不要这样做,因为它可能无法在所有编译器上工作!

printf调用 write 还是 write 调用printf

printf调用writeprintf包括一个内部缓冲区,所以为了提高性能,printf可能不会在每次调用printf时都调用writeprintf是一个 C 库函数。write是一个系统调用,我们知道系统调用是昂贵的。另一方面,printf使用一个更适合我们需求的缓冲区

如何打印出指针值?整数?字符串?

使用格式说明符“%p”表示指针,“%d”表示整数,“%s”表示字符串。所有格式说明符的完整列表在这里中找到

整数的例子:

int num1 = 10;
printf("%d", num1); //prints num1

整数指针的例子:

int *ptr = (int *) malloc(sizeof(int));
*ptr = 10;
printf("%p\n", ptr); //prints the address pointed to by the pointer
printf("%p\n", &ptr); /*prints the address of pointer -- extremely useful
when dealing with double pointers*/
printf("%d", *ptr); //prints the integer content of ptr
return 0;

字符串的例子:

char *str = (char *) malloc(256 * sizeof(char));
strcpy(str, "Hello there!");
printf("%p\n", str); // print the address in the heap
printf("%s", str);
return 0;

字符串作为指针和数组@ BU

如何将标准输出保存到文件?

最简单的方法:运行你的程序并使用 shell 重定向,例如

./program > output.txt

#To read the contents of the file,
cat output.txt 

更复杂的方法:关闭(1),然后使用 open 重新打开文件描述符。参见cs-education.github.io/sys/#chapter/0/section/3/activity/0

指针和数组有什么区别?举一个你可以用其中一个做而另一个做不到的例子。

char ary[] = "Hello";
char *ptr = "Hello";

例子

数组名指向数组的第一个字节。aryptr都可以打印出来:

char ary[] = "Hello";
char *ptr = "Hello";
// Print out address and contents
printf("%p : %s\n", ary, ary);
printf("%p : %s\n", ptr, ptr);

数组是可变的,所以我们可以改变它的内容(但要小心不要写超出数组末尾的字节)。幸运的是,“World”不会比“Hello”更长

在这种情况下,char 指针ptr指向一些只读内存(静态分配的字符串文字存储的地方),所以我们不能改变这些内容。

strcpy(ary, "World"); // OK
strcpy(ptr, "World"); // NOT OK - Segmentation fault (crashes)

然而,与数组不同的是,我们可以将ptr更改为指向另一块内存,

ptr = "World"; // OK!
ptr = ary; // OK!
ary = (..anything..) ; // WONT COMPILE
// ary is doomed to always refer to the original array.
printf("%p : %s\n", ptr, ptr);
strcpy(ptr, "World"); // OK because now ptr is pointing to mutable memory (the array)

从中可以得出的结论是指针*可以指向任何类型的内存,而 C 数组[]只能指向堆栈上的内存。在更常见的情况下,指针将指向堆内存,这种情况下指针引用的内存是可以修改的。

sizeof()返回字节数。所以使用上面的代码,aryptrsizeof()分别是多少?

sizeof(ary): ary是一个数组。返回整个数组所需的字节数(5 个字符+零字节=6 个字节)sizeof(ptr): 与sizeof(char *)相同。返回指针所需的字节数(例如 32 位或 64 位机器的 4 或 8)

sizeof是一个特殊的运算符。实际上,它是编译程序之前编译器替换的东西,因为所有类型的大小在编译时是已知的。当你有sizeof(char*)时,它会获取你的机器上指针的大小(64 位机器为 8 字节,32 位机器为 4 字节等)。当你尝试sizeof(char[])时,编译器会查看并替换整个数组包含的字节数,因为数组的总大小在编译时是已知的。

char str1[] = "will be 11";
char* str2 = "will be 8";
sizeof(str1) //11 because it is an array
sizeof(str2) //8 because it is a pointer

小心,使用 sizeof 获取字符串的长度!

以下代码中哪些是不正确的或正确的,为什么?

int* f1(int *p) {
    *p = 42;
    return p;
} // This code is correct;
char* f2() {
    char p[] = "Hello";
    return p;
} // Incorrect!

解释:在堆栈上为包含 H,e,l,l,o 和一个空字节即(6)字节的正确大小创建了一个数组 p。这个数组存储在堆栈上,在我们从 f2 返回后就无效了。

char* f3() {
    char *p = "Hello";
    return p;
} // OK

解释:p 是一个指针。它保存了字符串常量的地址。字符串常量在 f3 返回后仍然保持不变和有效。

char* f4() {
    static char p[] = "Hello";
    return p;
} // OK

解释:数组是静态的,这意味着它存在于进程的整个生命周期(静态变量不在堆或栈上)。

如何查找 C 库调用和系统调用的信息?

使用 man 手册。请注意,man 手册分为几个部分。第二部分=系统调用。第三部分=C 库。网络:谷歌“man7 open” shell:man -S2 open 或 man -S3 printf

如何在堆上分配内存?

使用 malloc。还有 realloc 和 calloc。通常与 sizeof 一起使用。例如,足够的空间来容纳 10 个整数

int *space = malloc(sizeof(int) * 10);

这个字符串复制代码有什么问题?

void mystrcpy(char*dest, char* src) { 
  // void means no return value 
  while( *src ) { dest = src; src ++; dest++; }  
}

在上面的代码中,它只是改变了 dest 指针指向源字符串。而且 nuls 字节没有被复制。这是一个更好的版本 -

 while( *src ) { *dest = *src; src ++; dest++; } 
  *dest = *src; 

请注意,通常还会看到以下类型的实现,其中包括在表达式测试中执行所有操作,包括复制 nul 字节。

  while( (*dest++ = *src++ )) {};

如何编写一个 strdup 替代品?

// Use strlen+1 to find the zero byte... 
char* mystrdup(char*source) {
   char *p = (char *) malloc ( strlen(source)+1 );
   strcpy(p,source);
   return p;
}

如何在堆上取消分配内存?

使用 free!

int *n = (int *) malloc(sizeof(int));
*n = 10;
//Do some work
free(n);

什么是双重释放错误?如何避免?什么是悬空指针?如何避免?

双重释放错误是当您意外地尝试两次释放相同的分配时发生的。

int *p = malloc(sizeof(int));
free(p);

*p = 123; // Oops! - Dangling pointer! Writing to memory we don't own anymore

free(p); // Oops! - Double free!

修复首先是编写正确的程序!其次,一旦内存被释放,重置指针是良好的编程习惯。这确保了指针在没有程序崩溃的情况下不能被错误使用。

修复:

p = NULL; // Now you can't use this pointer by mistake

缓冲区溢出的一个例子是什么?

著名的例子:心脏出血(将一个 memcpy 复制到一个不足大小的缓冲区)。简单的例子:实现一个 strcpy 并忘记在确定所需内存大小时添加一个 strlen。

“typedef”是什么,你如何使用它?

声明类型的别名。通常与结构一起使用,以减少必须将“struct”写为类型的一部分的视觉混乱。

typedef float real; 
real gravity = 10;
// Also typedef gives us an abstraction over the underlying type used. 
// For example in the future we only need to change this typedef if we
// wanted our physics library to use doubles instead of floats.

typedef struct link link_t; 
//With structs, include the keyword 'struct' as part of the original types

在这个课程中,我们经常使用 typedef 函数。例如,函数的 typedef 可以是这样的

typedef int (*comparator)(void*,void*);

int greater_than(void* a, void* b){
    return a > b;
}
comparator gt = greater_than;

这声明了一个接受两个void*参数并返回整数的比较器函数类型。

哇,这是很多 C 的内容

别担心,还有更多要来的!

下一步:C 编程,第二部分:文本输入和输出

C 编程,第二部分:文本输入和输出

打印到流

如何将字符串、整数、字符打印到标准输出流中?

使用 printf。第一个参数是格式字符串,其中包括要打印的数据的占位符。常见的格式说明符是 %s 将参数视为 C 字符串指针,一直打印到达到 NULL 字符为止;%d 将参数打印为整数;%p 将参数打印为内存地址。

下面显示了一个简单的示例:

char *name = ... ; int score = ...;
printf("Hello %s, your result is %d\n", name, score);
printf("Debug: The string and int are stored at: %p and %p\n", name, &score );
// name already is a char pointer and points to the start of the array. 
// We need "&" to get the address of the int variable

默认情况下,为了性能,printf 实际上并不会写任何东西(通过调用 write),直到它的缓冲区满或打印换行符。

我还可以如何打印字符串和单个字符?

使用 puts( name );putchar( c ),其中 name 是指向 C 字符串的指针,c 只是一个 char

如何将内容打印到其他文件流中?

使用 fprintf( _file_ , "Hello %s, score: %d", name, score); 其中 file 是预定义的 'stdout' 'stderr' 或者是由 fopenfdopen 返回的 FILE 指针

我可以使用文件描述符吗?

是的!只需使用 dprintf(int fd, char* format_string, ...); 只需记住流可能是缓冲的,所以您需要确保数据被写入文件描述符。

如何将数据打印到 C 字符串中?

使用 sprintf 或更好的 snprintf

char result[200];
int len = snprintf(result, sizeof(result), "%s:%d", name, score);

snprintf 返回写入的字符数,不包括终止字节。在上面的示例中,这将是最多 199 个。

如果我真的非常想要 printf 调用 write 而不换行怎么办?

使用 fflush( FILE* inp )。文件的内容将被写入。如果我想要写入 "Hello World" 而不换行,我可以这样写。

int main(){
    fprintf(stdout, "Hello World");
    fflush(stdout);
    return 0;
}

perror 有什么帮助?

假设您有一个函数调用刚刚失败了(因为您检查了 man 页面并且它是一个失败的返回代码)。perror(const char* message) 将把错误的英文版本打印到 stderr

int main(){
    int ret = open("IDoNotExist.txt", O_RDONLY);
    if(ret < 0){
        perror("Opening IDoNotExist:");
    }
    //...
    return 0;
}

解析输入

如何从字符串中解析数字?

使用 long int strtol(const char *nptr, char **endptr, int base);long long int strtoll(const char *nptr, char **endptr, int base);

这些函数的作用是获取指向您的字符串 *nptr 和一个 base(即二进制、八进制、十进制、十六进制等)以及一个可选的指针 endptr,并返回解析的整数。

int main(){
    const char *num = "1A2436";
    char* endptr;
    long int parsed = strtol(num, &endptr, 16);
    return 0;
}

但要小心!错误处理有点棘手,因为该函数不会返回错误代码。出错时,它将返回 0,您必须手动检查 errno,但这可能会导致麻烦。

int main(){
    const char *zero = "0";
    char* endptr;
    printf("Parsing number"); //printf sets errno
    long int parsed = strtol(num, &endptr, 16);
    if(parsed == 0){
        perror("Error: "); //oops strtol actually worked!
    }
    return 0;
}

如何使用 scanf 解析输入为参数?

使用 scanf(或 fscanfsscanf)从默认输入流、任意文件流或 C 字符串中获取输入。检查返回值以查看解析了多少项是个好主意。scanf 函数需要有效的指针。将错误的指针值传入是一个常见的错误来源。例如,

int *data = (int *) malloc(sizeof(int));
char *line = "v 10";
char type;
// Good practice: Check scanf parsed the line and read two values:
int ok = 2 == sscanf(line, "%c %d", &type, &data); // pointer error

我们想要将字符值写入 c,将整数值写入 malloc'd 内存。然而我们传递的是数据指针的地址,而不是指针指向的内容!所以 sscanf 将会改变指针本身。也就是说,指针现在将指向地址 10,所以这段代码以后会失败,例如当调用 free(data) 时。

如何阻止 scanf 导致缓冲区溢出?

以下代码假设 scanf 不会读取超过 10 个字符(包括终止字节)到缓冲区中。

char buffer[10];
scanf("%s",buffer);

您可以包含一个可选的整数来指定多少个字符,不包括终止字节:

char buffer[10];
scanf("%9s", buffer); // reads upto 9 charactes from input (leave room for the 10th byte to be the terminating byte)

为什么 gets 是危险的?我应该用什么代替?

以下代码容易受到缓冲区溢出的影响。它假定或信任输入行不会超过 10 个字符,包括终止字节。

char buf[10];
gets(buf); // Remember the array name means the first byte of the array

gets 在 C99 标准中已被弃用,并且已从最新的 C 标准(C11)中删除。程序应该使用 fgetsgetline 代替。

它们分别具有以下结构:

char *fgets (char *str, int num, FILE *stream); 

ssize_t getline(char **lineptr, size_t *n, FILE *stream);

下面是一种简单、安全的读取单行的方法。超过 9 个字符的行将被截断:

char buffer[10];
char *result = fgets(buffer, sizeof(buffer), stdin);

如果出现错误或者到达文件末尾,结果将为 NULL。请注意,与gets不同,fgets会将换行符复制到缓冲区中,您可能希望将其丢弃-

if (!result) { return; /* no data - don't read the buffer contents */}

int i = strlen(buffer) - 1;
if (buffer[i] == '\n') 
    buffer[i] = '\0';

我如何使用getline

getline的优点之一是它将自动(重新)分配足够大小的堆上的缓冲区。

// ssize_t getline(char **lineptr, size_t *n, FILE *stream);

 /* set buffer and size to 0; they will be changed by getline */
char *buffer = NULL;
size_t size = 0;

ssize_t chars = getline(&buffer, &size, stdin);

// Discard newline character if it is present,
if (chars > 0 && buffer[chars-1] == '\n') 
    buffer[chars-1] = '\0';

// Read another line.
// The existing buffer will be re-used, or, if necessary,
// It will be `free`'d and a new larger buffer will `malloc`'d
chars = getline(&buffer, &size, stdin);

// Later... don't forget to free the buffer!
free(buffer);

C 编程,第三部分:常见陷阱

C 程序员常犯哪些常见错误?

内存错误

字符串常量是常量

char array[] = "Hi!"; // array contains a mutable copy 
strcpy(array, "OK");

char *ptr = "Can't change me"; // ptr points to some immutable memory
strcpy(ptr, "Will not work");

字符串文字是存储在程序的代码段中的字符数组,是不可变的。两个字符串文字可能共享内存中的相同空间。以下是一个例子:

char * str1 = "Brandon Chong is the best TA";
char * str2 = "Brandon Chong is the best TA";

str1str2指向的字符串实际上可能驻留在内存中的相同位置。

但是,char 数组包含了从代码段复制到堆栈或静态内存中的文字值。以下 char 数组不驻留在内存中的相同位置。

char arr1[] = "Brandon Chong didn't write this";
char arr2[] = "Brandon Chong didn't write this";

缓冲区溢出/下溢

#define N (10)
int i = N, array[N];
for( ; i >= 0; i--) array[i] = i;

C 语言不检查指针是否有效。上面的例子写入了array[10],这超出了数组边界。这可能会导致内存损坏,因为该内存位置可能正在用于其他用途。实际上,这可能更难发现,因为溢出/下溢可能发生在库调用中。

gets(array); // Let's hope the input is shorter than my array!

返回指向自动变量的指针

int *f() {
    int result = 42;
    static int imok;
    return &imok; // OK - static variables are not on the stack
    return &result; // Not OK
}

自动变量仅绑定到函数的堆栈内存,函数的生命周期结束后继续使用内存是错误的。

内存分配不足

struct User {
   char name[100];
};
typedef struct User user_t;

user_t *user = (user_t *) malloc(sizeof(user));

在上面的例子中,我们需要为结构体分配足够的字节。相反,我们分配了足够的字节来容纳一个指针。一旦我们开始使用用户指针,就会破坏内存。正确的代码如下所示。

struct User {
   char name[100];
};
typedef struct User user_t;

user_t * user = (user_t *) malloc(sizeof(user_t));

字符串需要strlen(s)+1字节

每个字符串在最后一个字符后必须有一个空字节。存储字符串"Hi"需要 3 个字节:[H] [i] [\0]

  char *strdup(const char *input) {  /* return a copy of 'input' */
    char *copy;
    copy = malloc(sizeof(char*));     /* nope! this allocates space for a pointer, not a string */
    copy = malloc(strlen(input));     /* Almost...but what about the null terminator? */
    copy = malloc(strlen(input) + 1); /* That's right. */
    strcpy(copy, input);   /* strcpy will provide the null terminator */
    return copy;
}

使用未初始化的变量

int myfunction() {
  int x;
  int y = x + 2;
...

自动变量保存垃圾(内存中发生的任何位模式)。假设它总是初始化为零是错误的。

假设未初始化的内存将被清零

void myfunct() {
   char array[10];
   char *p = malloc(10);

自动(临时变量)不会自动初始化为零。使用 malloc 进行堆分配不会自动初始化为零。

双重释放

  char *p = malloc(10);
  free(p);
//  .. later ...
  free(p); 

多次释放同一块内存是错误的。

悬空指针

  char *p = malloc(10);
  strcpy(p, "Hello");
  free(p);
//  .. later ...
  strcpy(p,"World"); 

不应使用指向释放内存的指针。一种防御性编程实践是在释放内存后立即将指针设置为 null。

将免费转换为以下片段是一个好主意,它会自动将释放的变量设置为 null:(vim - ultisnips)

snippet free "free(something)" b
free(${1});
$1 = NULL;
${2}
endsnippet

逻辑和程序流错误

忘记 break

int flag = 1; // Will print all three lines.
switch(flag) {
  case 1: printf("I'm printed\n");
  case 2: printf("Me too\n");
  case 3: printf("Me three\n");
}

没有 break 的 case 语句将继续执行下一个 case 语句的代码。正确的代码如下所示。最后一个语句的 break 是不必要的,因为在最后一个语句之后没有更多的要执行的情况。但是,如果添加了更多的情况,可能会导致一些错误。

int flag = 1; // Will print only "I'm printed\n"
switch(flag) {
  case 1: 
    printf("I'm printed\n");
    break;
  case 2: 
    printf("Me too\n");
    break;
  case 3: 
    printf("Me three\n");
    break; //unnecessary
}

等号和相等

int answer = 3; // Will print out the answer.
if (answer = 42) { printf("I've solved the answer! It's %d", answer);}

未声明或错误声明的函数

time_t start = time();

系统函数'time'实际上需要一个参数(一个指向可以接收 time_t 结构的一些内存的指针)。编译器没有捕获到这个错误,因为程序员没有通过包含time.h提供有效的函数原型。

额外的分号

for(int i = 0; i < 5; i++) ; printf("I'm printed once");
while(x < 10); x++ ; // X is never incremented

然而,以下代码是完全可以的。

for(int i = 0; i < 5; i++){
    printf("%d\n", i);;;;;;;;;;;;;
}

这种代码是可以的,因为 C 语言使用分号(;)来分隔语句。如果分号之间没有语句,那么就没有要做的事情,编译器会继续执行下一条语句。

其他陷阱

预处理器

预处理器是什么?它是编译器在实际编译程序之前执行的操作。它是一个复制和粘贴命令。这意味着如果我做以下操作。

#define MAX_LENGTH 10
char buffer[MAX_LENGTH]

预处理后,它会变成这样。

char buffer[10]

C 预处理宏和副作用

#define min(a,b) ((a)<(b) ? (a) : (b))
int x = 4;
if(min(x++, 100)) printf("%d is six", x);

宏是简单的文本替换,因此上面的例子会扩展为x++ < 100 ? x++ : 100(为了清晰起见省略了括号)

C 预处理宏和优先级

#define min(a,b) a<b ? a : b
int x = 99;
int r = 10 + min(99, 100); // r is 100!

宏是简单的文本替换,因此上面的例子会扩展为10 + 99 < 100 ? 99 : 100

C 预处理逻辑陷阱

#define ARRAY_LENGTH(A) (sizeof((A)) / sizeof((A)[0]))
int static_array[10]; // ARRAY_LENGTH(static_array) = 10
int* dynamic_array = malloc(10); // ARRAY_LENGTH(dynamic_array) = 2 or 1

宏有什么问题?如果我们有一个像第一个数组那样的静态数组,它就能工作,因为静态数组的 sizeof 返回数组占用的字节数,将其除以 sizeof(an_element)将给出条目的数量。但是,如果我们使用指向内存块的指针,取指针的 sizeof 并将其除以第一个条目的大小并不总是会给出数组的大小。

sizeof 有什么作用吗?

int a = 0;
size_t size = sizeof(a++);
printf("size: %lu, a: %d", size, a);

代码打印出什么?

size: 4, a: 0 

因为 sizeof 实际上不是在运行时评估的。编译器为所有表达式分配类型并丢弃表达式的额外结果。

C 编程,第四部分:字符串和结构

字符串、结构和陷阱

那么什么是字符串?

Crash String

在 C 中,���们使用空终止字符串,而不是长度前缀,出于历史原因。对于你平常的编程来说,这意味着你需要记住空字符!在 C 中,字符串被定义为一堆字节,直到你达到'\0'或空字节为止。

字符串的两个位置

每当你定义一个常量字符串(即形式为char* str = "constant"的字符串)时,该字符串存储在数据代码段中,这是只读的,这意味着任何尝试修改字符串都会导致段错误。

然而,如果有人malloc空间,就可以更改该字符串为他们想要的任何内容。

内存管理不善

一个常见的陷阱是当你写下面的内容时

char* hello_string = malloc(14);
                       ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
// hello_string ----> | g | a | r | b | a | g | e | g | a | r | b | a | g | e |
                       ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾
hello_string = "Hello Bhuvan!";
// (constant string in the text segment)
// hello_string ----> [ "H" , "e" , "l" , "l" , "o" , " " , "B" , "h" , "u" , "v" , "a" , "n" , "!" , "\0" ]
                       ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
// memory_leak -----> | g | a | r | b | a | g | e | g | a | r | b | a | g | e |
                       ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾
hello_string[9] = 't'; //segfault!!

我们做了什么?我们为 14 个字节分配了空间,重新分配了指针,成功地导致了段错误!记住跟踪你的指针在做什么。你可能想要做的是使用string.h函数strcpy

strcpy(hello_string, "Hello Bhuvan!");

记住空字节!

忘记对字符串进行空终止会对字符串产生重大影响!边界检查很重要。前面在 wikibook 中提到的 heartbleed 漏洞部分是因为这个原因。

我在哪里可以找到所有这些函数的深入和全面的解释?

就在这里!

字符串信息/比较:strlen strcmp

int strlen(const char *s) 返回字符串的长度,不包括空字节

int strcmp(const char *s1, const char *s2) 返回一个整数,确定字符串的词典顺序。如果 s1 在字典中出现在 s2 之前,则返回-1。如果两个字符串相等,则返回 0。否则返回 1。

对于大多数这些函数,它们期望字符串是可读的,而不是NULL,但是当你传递NULL时会出现未定义的行为。

字符串修改:strcpy strcat strdup

char *strcpy(char *dest, const char *src)src的字符串复制到dest假设 dest 有足够的空间容纳 src

char *strcat(char *dest, const char *src)src的字符串连接到目的地的末尾。此函数假定目的地末尾有足够的空间容纳src,包括空字节

char *strdup(const char *dest) 返回字符串的malloc副本。

字符串搜索:strchr strstr

char *strchr(const char *haystack, int needle) 返回haystackneedle第一次出现的指针。如果找不到,则返回NULL

char *strstr(const char *haystack, const char *needle) 与上面相同,但这次是一个字符串!

字符串标记化:strtok

一个危险但有用的函数strtok接受一个字符串并对其进行标记化。这意味着它将把字符串转换为单独的字符串。这个函数有很多规范,所以请阅读 man 页面,下面是一个人为的例子。

#include <stdio.h>
#include <string.h>

int main(){
    char* upped = strdup("strtok,is,tricky,!!");
    char* start = strtok(upped, ",");
    do{
        printf("%s\n", start);
    }while((start = strtok(NULL, ",")));
    return 0;
}

输出

strtok
is
tricky
!!

当我像这样改变upped时会发生什么?

char* upped = strdup("strtok,is,tricky,,,!!");

内存移动:memcpymemmove

为什么memcpymemmove都在<string.h>中?因为字符串本质上是带有空字节的原始内存!

void *memcpy(void *dest, const void *src, size_t n) 将从str开始的n个字节移动到dest小心 当内存区域重叠时会出现未定义的行为。这是一个经典的“在我的机器上工作”的例子,因为很多时候 valgrind 无法检测到它,因为在你的机器上它看起来是有效的。当自动评分器出现时,会失败。考虑更安全的版本。

void *memmove(void *dest, const void *src, size_t n) 做与上述相同的事情,但如果内存区域重叠,则保证所有字节都会正确复制过去。

那么struct是什么?

Struct Example

从低级别来看,一个结构体只是一块连续的内存,仅此而已。就像数组一样,结构体有足够的空间来存储所有的成员。但与数组不同,它可以存储不同的类型。考虑上面声明的 contact 结构。

struct contact {
    char firstname[20];
    char lastname[20];
    unsigned int phone;
};

struct contact bhuvan;

简短的插曲

/* a lot of times we will do the following typdef
 so we can just write contact contact1 */

typedef struct contact contact;
contact bhuvan;

/* You can also declare the struct like this to get
 it done in one statement */
typedef struct optional_name {
    ...
} contact;

如果你在没有任何优化和重新排序的情况下编译代码,你可以期望每个变量的地址看起来像这样。

&bhuvan           // 0x100
&bhuvan.firstname // 0x100 = 0x100+0x00
&bhuvan.lastname  // 0x114 = 0x100+0x14
&bhuvan.phone     // 0x128 = 0x100+0x28

因为你的编译器所做的就是说'嘿,保留这么多空间,我会去计算你想要写入的任何变量的偏移量'。

这些偏移量是什么意思?

偏移量是变量开始的地方。电话变量从第0x128字节开始,持续 sizeof(int)字节,但并非总是如此。偏移量并不决定变量的结束位置。考虑在许多内核代码中看到的以下黑客行为。

typedef struct {
    int length;
    char c_str[0];
} string;

const char* to_convert = "bhuvan";
int length = strlen(to_convert);

// Let's convert to a c string
string* bhuvan_name;
bhuvan_name = malloc(sizeof(string) + length+1);
/*
Currently, our memory looks like this with junk in those black spaces
 ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
 bhuvan_name = |   |   |   |   |   |   |   |   |   |   |   |
 ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾
*/

bhuvan_name->length = length;
/*
This writes the following values to the first four bytes
The rest is still garbage
 ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
 bhuvan_name = | 0 | 0 | 0 | 6 |   |   |   |   |   |   |   |
 ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾
*/

strcpy(bhuvan_name->c_str, to_convert);
/*
Now our string is filled in correctly at the end of the struct
 ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ____
 bhuvan_name = | 0 | 0 | 0 | 6 | b | h | u | v | a | n | \0 |
 ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾‾
*/

strcmp(bhuvan_name->c_str, "bhuvan") == 0 //The strings are equal!

但并不是所有的结构都是完美的

结构体可能需要一些叫做填充(教程)的东西。**我们不指望你在这门课程中对结构体进行打包,只是知道它存在。这是因为在早期(甚至现在)当你必须从内存中获取一个地址时,你必须以 32 位或 64 位块的方式进行。这也意味着你只能请求那些是它的倍数的地址。这意味着

struct picture{
    int height;
    pixel** data;
    int width;
    char* enconding;
}
// You think picture looks like this
           height      data         width     encoding
           ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
picture = |       |               |       |               |
           ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾

概念上可能看起来像这样

struct picture{
    int height;
    char slop1[4];
    pixel** data;
    int width;
    char slop2[4];
    char* enconding;
}
           height   slop1       data        width   slop2  encoding
           ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
picture = |       |       |               |       |       |               |
           ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾

(这是在 64 位系统上)这并不总是这样,因为有时处理器支持不对齐访问。这是什么意思?嗯,有两种选择你可以设置一个属性

struct __attribute__((packed, aligned(4))) picture{
    int height;
    pixel** data;
    int width;
    char* enconding;
}
// Will look like this
           height       data        width     encoding
           ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
picture = |       |               |       |               |
           ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾

但现在每次我想要访问dataencoding,我都必须进行两次内存访问。你可以做的另一件事是重新排列结构,尽管这并不总是可能的

struct picture{
    int height;
    int width;
    pixel** data;
    char* enconding;
}
// You think picture looks like this
           height   width        data         encoding
           ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___
picture = |       |       |               |               |
           ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾ ‾‾‾

C 编程,第五部分:调试

《C 程序调试指南》

这将是一个帮助您调试 C 程序的大型指南。您可以检查错误的不同级别,我们将逐个介绍。请随时添加您在调试 C 程序中发现有用的任何内容,包括但不限于,调试器的使用,识别常见错误类型,陷阱和有效的搜索技巧。

在代码中调试

清洁代码

使用辅助函数使您的代码模块化。如果有重复的任务(例如在 MP2 中获取连续块的指针),请将它们制作为辅助函数。确保每个函数都非常擅长做一件事,这样您就不必两次调试。

假设我们正在通过每次迭代找到最小元素来进行选择排序,如下所示,

void selection_sort(int *a, long len){
     for(long i = len-1; i > 0; --i){
         long max_index = i;
         for(long j = len-1; j >= 0; --j){
             if(a[max_index] < a[j]){
                  max_index = j;
             }
         }
         int temp = a[i];
         a[i] = a[max_index];
         a[max_index] = temp;
     }

}

许多人可以看到代码中的错误,但将上述方法重构为

long max_index(int *a, long start, long end);
void swap(int *a, long idx1, long idx2);
void selection_sort(int *a, long len);

而错误特别在一个函数中。

最后,我们不是一个关于重构/调试代码的课程--事实上,大多数系统代码都很糟糕,你不想读它。但是为了调试,长远来看,采用一些实践可能对你有好处。

断言!

使用断言来确保您的代码在某个特定点之前工作--并且重要的是,确保您以后不会破坏它。例如,如果您的数据结构是双向链表,您可以这样做,assert(node->size == node->next->prev->size)来断言下一个节点指向当前节点。您还可以检查指针是否指向预期的内存地址范围,而不是 null,->size 是合理的等等。NDEBUG 宏将禁用所有断言,因此在调试完成后不要忘记设置它。www.cplusplus.com/reference/cassert/assert/

使用 assert 的一个快速示例是,假设我正在使用 memcpy 编写代码

assert(!(src < dest+n && dest < src+n)); //Checks overlap
memcpy(dest, src, n);

这个检查可以在编译时关闭,但会帮助您避免大量的调试麻烦!

printfs

当一切都失败时,疯狂地打印!您的每个函数都应该知道它要做什么(例如,find_min 最好找到最小的元素)。您希望测试每个函数是否正在做它设定的事情,并确切地查看代码在哪里出错。在竞态条件的情况下,tsan 可能有所帮助,但让每个线程在特定时间打印数据可能有助于您识别竞态条件。

Valgrind

(待办事项)

Tsan

ThreadSanitizer 是 Google 的一个工具,内置在 clang(和 gcc)中,可以帮助您检测代码中的竞态条件。有关该工具的更多信息,请参阅 Github 维基。

请注意,使用 tsan 会使您的代码变慢一些。

#include <pthread.h>
#include <stdio.h>

int Global;

void *Thread1(void *x) {
    Global++;
    return NULL;
}

int main() {
    pthread_t t[2];
    pthread_create(&t[0], NULL, Thread1, NULL);
    Global = 100;
    pthread_join(t[0], NULL);
}
// compile with gcc -fsanitize=thread -pie -fPIC -ltsan -g simple_race.c

我们可以看到变量 Global 存在竞态条件。主线程和使用 pthread_create 创建的线程将尝试同时更改值。但是,ThreadSantizer 能否捕捉到它呢?

$ ./a.out
==================
WARNING: ThreadSanitizer: data race (pid=28888)
  Read of size 4 at 0x7f73ed91c078 by thread T1:
    #0 Thread1 /home/zmick2/simple_race.c:7 (exe+0x000000000a50)
    #1  :0 (libtsan.so.0+0x00000001b459)

  Previous write of size 4 at 0x7f73ed91c078 by main thread:
    #0 main /home/zmick2/simple_race.c:14 (exe+0x000000000ac8)

  Thread T1 (tid=28889, running) created by main thread at:
    #0  :0 (libtsan.so.0+0x00000001f6ab)
    #1 main /home/zmick2/simple_race.c:13 (exe+0x000000000ab8)

SUMMARY: ThreadSanitizer: data race /home/zmick2/simple_race.c:7 Thread1
==================
ThreadSanitizer: reported 1 warnings 

如果我们使用调试标志编译,那么它将给我们变量名。

GDB

介绍:www.cs.cmu.edu/~gilpin/tutorial/

以编程方式设置断点

在使用 GDB 调试复杂的 C 程序时,一个非常有用的技巧是在源代码中设置断点。

int main() {
    int val = 1;
    val = 42;
    asm("int $3"); // set a breakpoint here
    val = 7;
}
$ gcc main.c -g -o main && ./main
(gdb) r
[...]
Program received signal SIGTRAP, Trace/breakpoint trap.
main () at main.c:6
6       val = 7;
(gdb) p val
$1 = 42

检查内存内容

www.delorie.com/gnu/docs/gdb/gdb_56.html

例如,

int main() {
    char bad_string[3] = {'C', 'a', 't'};
    printf("%s", bad_string);
}
$ gcc main.c -g -o main && ./main
$ Cat ZVQ�� $
(gdb) l
1   #include <stdio.h>
2   int main() {
3       char bad_string[3] = {'C', 'a', 't'};
4       printf("%s", bad_string);
5   }
(gdb) b 4
Breakpoint 1 at 0x100000f57: file main.c, line 4.
(gdb) r
[...]
Breakpoint 1, main () at main.c:4
4       printf("%s", bad_string);
(gdb) x/16xb bad_string
0x7fff5fbff9cd: 0x63    0x61    0x74    0xe0    0xf9    0xbf    0x5f    0xff
0x7fff5fbff9d5: 0x7f    0x00    0x00    0xfd    0xb5    0x23    0x89    0xff

(gdb)

在这里,通过使用带有参数16xbx命令,我们可以看到从内存地址0x7fff5fbff9cbad_string的值)开始,printf 实际上会看到以下字节序列作为字符串,因为我们提供了一个没有空终止符的格式不正确的字符串。

0x43 0x61 0x74 0xe0 0xf9 0xbf 0x5f 0xff 0x7f 0x00

C 编程,复习问题

主题

  • C 字符串表示

  • C 字符串作为指针

  • char p[]vs char* p

  • 简单的 C 字符串函数(strcmp,strcat,strcpy)

  • sizeof char

  • sizeof x vs x*

  • 堆内存寿命

  • 堆分配调用

  • 解引用指针

  • 取地址运算符

  • 指针算术

  • 字符串复制

  • 字符串截断

  • 双重释放错误

  • 字符串字面值

  • 打印格式。

  • 内存越界错误

  • 静态内存

  • fileio POSIX v C 库

  • C io fprintf 和 printf

  • POSIX 文件 io(读|写|打开)

  • stdout 的缓冲

问题/练习

  • 以下打印出什么
int main(){
    fprintf(stderr, "Hello ");
    fprintf(stdout, "It's a small ");
    fprintf(stderr, "World\n");
    fprintf(stdout, "place\n");
    return 0;
}
  • 以下两个声明之间有什么区别?其中一个的sizeof返回什么?
char str1[] = "bhuvan";
char *str2 = "another one";
  • C 中的字符串是什么?

  • 编写一个简单的my_strcmpmy_strcatmy_strcpymy_strdup呢?奖励:只通过字符串一次编写函数。

  • 以下通常应该返回什么?

int *ptr;
sizeof(ptr);
sizeof(*ptr);
  • 什么是malloc?它与calloc有什么不同。一旦内存被malloc,我如何使用realloc

  • &运算符是什么?*呢?

  • 指针算术。假设以下地址。以下移位是什么?

char** ptr = malloc(10); //0x100
ptr[0] = malloc(20); //0x200
ptr[1] = malloc(20); //0x300
 * `ptr + 2`
 * `ptr + 4`
 * `ptr[0] + 4`
 * `ptr[1] + 2000`
 * `*((int)(ptr + 1)) + 3` 
  • 我们如何防止双重释放错误?

  • 打印字符串,intchar的 printf 格式说明符是什么?

  • 以下代码有效吗?如果是,为什么?output位于哪里?

char *foo(int var){
    static char output[20];
    snprintf(output, 20, "%d", var);
    return output;
}
  • 编写一个接受字符串并打开该文件的函数,每次打印出文件的 40 个字节,但每隔一次打印都会颠倒字符串(尝试使用 POSIX API 实现)。

  • POSIX 文件描述符模型和 C 的FILE*之间有哪些区别(即使用了哪些函数调用,哪个是缓冲的)?POSIX 内部使用 C 的FILE*还是反之亦然?

返回:C 编程,第五部分:调试

二、进程

进程,第一部分:介绍

概述

进程是正在运行的程序(有点)。进程也只是计算机程序运行的一个实例。进程有很多可用的东西。在每个程序开始时,您会得到一个进程,但每个程序都可以创建更多的进程。事实上,您的操作系统只启动一个进程,所有其他进程都是从那个进程分叉出来的——在启动时都是在后台完成的。

在开始时

当您的 Linux 机器上的操作系统启动时,会创建一个名为init.d的进程。该进程是一个特殊的进程,处理信号、中断和某些内核元素的持久性模块。每当您想要创建一个新进程时,都会调用fork(将在后面的部分讨论)并使用另一个函数来加载另一个程序。

进程隔离

进程非常强大,但它们是隔离的!这意味着默认情况下,没有进程可以与另一个进程通信。这非常重要,因为如果您有一个庞大的系统(比如 EWS),那么您希望一些进程具有更高的特权(监控、管理),而您绝对不希望普通用户能够故意或者意外地通过修改进程来使整个系统崩溃。

如果我运行以下代码,

int secrets; //maybe defined in the kernel or else where
secrets++;
printf("%d\n", secrets); 

在两个不同的终端上,正如您所猜测的,它们都会打印出 1 而不是 2。即使我们改变代码以执行一些非常巧妙的操作(除了直接读取内存),也没有办法改变另一个进程的状态(好吧,也许这个,但那就有点太深入了)。

进程内容

内存布局

地址空间

当一个进程启动时,它会得到自己的地址空间。这意味着每个进程都会得到(对于内存

  • 堆栈。堆栈是存储自动变量和函数调用返回地址的地方。每次声明一个新变量,程序都会将堆栈指针向下移动,以保留变量的空间。堆栈的这一部分是可写的,但不可执行。如果堆栈增长得太远(意味着它要么超出了预设的边界,要么与堆相交),您很可能会得到堆栈溢出,最终导致段错误或类似的错误。默认情况下,堆栈是静态分配的,这意味着只有一定数量的空间可以写入

  • 。堆是一个不断扩大的内存区域。如果要分配一个大对象,它就会放在这里。堆从文本段的顶部开始向上增长(这意味着有时当您调用malloc时,它会要求操作系统将堆边界向上推)。这个区域也是可写的,但不可执行。如果系统受限或者地址用完了(在 32 位系统上更常见),就可能用完堆内存。

  • 数据段。这包含了所有的全局变量。这一部分从文本段的末尾开始,大小是静态的,因为全局变量的数量在编译时就已知。这一部分是可写的,但不可执行,没有其他太花哨的东西。

  • 文本段。这可以说是地址中最重要的部分。这是存储所有代码的地方。由于汇编编译成了 1 和 0,这就是 1 和 0 存储的地方。程序计数器在这个段中执行指令,并向下移动到下一个指令。重要的是要注意,这是代码中唯一可执行的部分。如果您尝试在运行时更改代码,很可能会导致段错误(虽然有办法绕过,但假设它会导致段错误)。

  • 为什么它不从零开始?这超出了本课程的范围,但这是出于安全考虑。

文件描述符

文件描述符

正如小册子所示,操作系统跟踪文件描述符及其指向的内容。我们将在后面看到,文件描述符不一定指向实际文件,操作系统会为您跟踪它们。另外,请注意,在进程之间文件描述符可能会被重用,但在进程内部它们是唯一的。

文件描述符也有位置的概念。您可以完全从磁盘上读取文件,因为操作系统跟踪文件中的位置,并且该位置也属于您的进程。

安全/权限

进程功能/限制(奖励)

当您复习期末考试时,您可以回来看看进程也具有所有这些东西。第一次看时 - 它可能不太有意义。

进程 ID(PID)

为了跟踪所有这些进程,您的操作系统为每个进程分配一个数字,该进程称为 PID,即进程 ID。

进程还可以包含

  • 映射

  • 状态

  • 文件描述符

  • 权限

分叉,第一部分:介绍

分叉,第一部分:介绍

警告

进程分叉是一个非常强大(也非常危险)的工具。如果出错并导致分叉炸弹(稍后在本页解释),你可能会导致整个系统崩溃。为了减少这种可能性,通过在命令行中输入ulimit -u 40来将最大进程数限制为一个小数字,例如 40。请注意,此限制仅适用于用户,这意味着如果你引发了分叉炸弹,那么你将无法杀死你刚刚创建的所有进程,因为调用killall需要你的 shell 来fork()...讽刺吧?那么我们该怎么办呢?一个解决方案是提前生成另一个用户(例如 root)的另一个 shell 实例并从那里杀死进程。另一个方法是使用内置的exec命令杀死所有用户进程(小心,你只有一次机会)。最后,你可以重新启动系统 :)

在测试fork()代码时,请确保你有根用户和/或物理访问权限。如果你必须远程处理fork()代码,请记住,在紧急情况下kill -9 -1会帮助你。

总结:如果你没有准备好,fork可能会非常危险。你已经被警告过了。

分叉介绍

fork做什么?

fork系统调用克隆当前进程以创建一个新进程。它通过复制现有进程的状态创建一个新进程(子进程),有一些细微的差异(下面讨论)。子进程不是从 main 开始。相反,它像父进程一样从fork()返回。

什么是最简单的fork()例子?

这是一个非常简单的例子...

printf("I'm printed once!\n");
fork();
// Now there are two processes running
// and each process will print out the next line.
printf("You see this line twice!\n");

为什么这个例子会打印两次 42?

以下程序打印出 42 两次 - 但fork()printf之后!?为什么?

#include <unistd.h> /*fork declared here*/
#include <stdio.h> /* printf declared here*/
int main() {
   int answer = 84 >> 1;
   printf("Answer: %d", answer);
   fork();
   return 0;
}

printf行*只执行一次,但请注意打印的内容没有刷新到标准输出(没有打印换行,我们没有调用fflush或更改缓冲模式)。因此,输出文本仍然在进程内存中等待发送。当执行fork()时,整个进程内存被复制,包括缓冲区。因此,子进程从一个非空输出缓冲区开始,该缓冲区将在程序退出时刷新。

如何编写针对父进程和子进程不同的代码?

检查fork()的返回值。返回值-1 = 失败;0 = 在子进程中;正数 = 在父进程中(返回值是子进程 id)。以下是记住哪个是哪个的一种方法:

子进程可以通过调用getppid()找到其父进程 - 被复制的原始进程 - 因此不需要从fork()获得任何额外的返回信息。然而,父进程只能从fork的返回值中找到新子进程的 id:

pid_t id = fork();
if (id == -1) exit(1); // fork failed 
if (id > 0)
{ 
// I'm the original parent and 
// I just created a child process with id 'id'
// Use waitpid to wait for the child to finish
} else { // returned zero
// I must be the newly made child process
}

什么是分叉炸弹?

'分叉炸弹'是指尝试创建无限数量的进程。下面是一个简单的例子:

while (1) fork();

这通常会使系统几乎停滞,因为它试图为大量准备运行的进程分配 CPU 时间和内存。评论:系统管理员不喜欢分叉炸弹,可能会设置每个用户可以拥有的进程数量的上限,或者可能会撤销登录权限,因为它会为其他用户的程序带来麻烦。你也可以使用setrlimit()来限制创建的子进程数量。

分叉炸弹并不一定是恶意的 - 它们偶尔会由于学生编码错误而发生。

Angrave 建议《黑客帝国》三部曲,机器和人最终共同努力击败不断增殖的 Agent-Smith,是基于一个基于 AI 驱动的分叉炸弹的电影情节。

等待和执行

父进程如何等待子进程完成?

使用waitpid(或wait)。

pid_t child_id = fork();
if (child_id == -1) { perror("fork"); exit(EXIT_FAILURE);}
if (child_id > 0) { 
  // We have a child! Get their exit code
  int status; 
  waitpid( child_id, &status, 0 );
  // code not shown to get exit status from child
} else { // In child ...
  // start calculation
  exit(123);
}

我可以让子进程执行另一个程序吗?

是的。在 fork 后使用其中一个exec函数。exec函数集用正在调用的进程映像替换进程映像。这意味着exec调用后的任何代码行都将被替换。任何其他要求子进程执行的工作都应该在exec调用之前完成。

Wikipedia 文章在帮助您理解 exec 系列名称方面做得很好。

命名方案可以缩短如下

每个的基础都是 exec(执行),后面跟着一个或多个字母:

e - 指向环境变量的指针数组被显式传递给新的进程映像。

l - 命令行参数被逐个传递(列表)给函数。

p - 使用 PATH 环境变量来查找要执行的文件名。

v - 命令行参数作为指针数组(向量)传递给函数。

#include <unistd.h>
#include <sys/types.h> 
#include <sys/wait.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char**argv) {
  pid_t child = fork();
  if (child == -1) return EXIT_FAILURE;
  if (child) { /* I have a child! */
    int status;
    waitpid(child , &status ,0);
    return EXIT_SUCCESS;

  } else { /* I am the child */
    // Other versions of exec pass in arguments as arrays
    // Remember first arg is the program name
    // Last arg must be a char pointer to NULL

    execl("/bin/ls", "ls","-alh", (char *) NULL);

    // If we get to this line, something went wrong!
    perror("exec failed!");
  }
}

执行另一个程序的简单方法

使用system。以下是如何使用它的方法:

#include <unistd.h>
#include <stdlib.h>

int main(int argc, char**argv) {
  system("ls");
  return 0;
}

system调用将分叉,执行由参数传递的命令,原始父进程将等待其完成。这也意味着system是一个阻塞调用:父进程在由system启动的进程退出之前无法继续。这可能有用,也可能没有。此外,system实际上创建了一个 shell,然后给出字符串,这比直接使用exec更耗费资源。标准 shell 将使用PATH环境变量搜索与命令匹配的文件名。对于许多简单的运行此命令问题,使用 system 通常足够了,但对于更复杂或微妙的问题,它可能很快变得有限,并且它隐藏了分叉-执行-等待模式的机制,因此我们鼓励您学习并使用fork execwaitpid

最愚蠢的 fork 示例是什么?

下面显示了一个稍微愚蠢的例子。它会打印什么?尝试使用多个参数运行您的程序。

#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv) {
  pid_t id;
  int status; 
  while (--argc && (id=fork())) {
    waitpid(id,&status,0); /* Wait for child*/
  }
  printf("%d:%s\n", argc, argv[argc]);
  return 0;
}

令人惊奇的并行明显 O(N) sleepsort是今天的愚蠢赢家。首次发布于2011 年的 4chan。下面显示了这种糟糕但有趣的排序算法的一个版本。

int main(int c, char **v)
{
        while (--c > 1 && !fork());
        int val  = atoi(v[c]);
        sleep(val);
        printf("%d\n", val);
        return 0;
}

注意:由于系统调度程序的工作方式,该算法实际上并不是 O(N)。虽然有并行算法可以在每个进程中以 O(log(N))运行,但这不幸地不是其中之一。

子进程与父进程有什么不同?

关键区别包括:

  • getpid()返回的进程 ID。由getppid()返回的父进程 ID。

  • 当子进程完成时,父进程通过信号 SIGCHLD 被通知,但反之则不然。

  • 子进程不会继承未决信号或定时器警报。完整列表请参阅fork man page

子进程共享打开的文件句柄吗?

是的!实际上,两个进程都使用相同的底层内核文件描述符。例如,如果一个进程将随机访问位置倒回到文件的开头,那么两个进程都会受到影响。

子进程和父进程都应该close(或fclose)它们的文件描述符或文件句柄。

如何获取更多信息?

阅读 man 页面!

分叉,第二部分:分叉,执行,等待

模式

以下的'exec'示例是做什么的?

#include <unistd.h>
#include <fcntl.h> // O_CREAT, O_APPEND etc. defined here

int main() {
   close(1); // close standard out
   open("log.txt", O_RDWR | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR);
   puts("Captain's log");
   chdir("/usr/include");
   // execl( executable,  arguments for executable including program name and NULL at the end)

   execl("/bin/ls", /* Remaining items sent to ls*/ "/bin/ls", ".", (char *) NULL); // "ls ."
   perror("exec failed");
   return 0; // Not expected
}

上述代码中没有错误检查(我们假设 close、open、chdir 等都按预期工作)。

  • open:将使用最低可用的文件描述符(即 1);因此标准输出现在转到日志文件。

  • chdir:将当前目录更改为/usr/include

  • execl:用/bin/ls 替换程序图像,并调用它的 main()方法

  • perror:我们不希望到达这里 - 如果到达了,那么 exec 失败了。

微妙的 fork 炸弹错误

这段代码有什么问题

#include <unistd.h>
#define HELLO_NUMBER 10

int main(){
    pid_t children[HELLO_NUMBER];
    int i;
    for(i = 0; i < HELLO_NUMBER; i++){
        pid_t child = fork();
        if(child == -1){
            break;
        }
        if(child == 0){ //I am the child
             execlp("ehco", "echo", "hello", NULL);
        }
        else{
            children[i] = child;
        }
    }

    int j;
    for(j = 0; j < i; j++){
        waitpid(children[j], NULL, 0);
    }
    return 0;
}

我们拼错了ehco,所以我们无法exec它。这是什么意思?我们只创建了 2**10 个进程,而不是 10 个进程,炸毁了我们的机器。我们如何防止这种情况?在 exec 后立即放置一个退出,这样如果 exec 失败,我们就不会炸毁我们的机器。

子进程从父进程继承了什么?

  • 打开文件句柄。如果父进程稍后寻求,比如,回到文件的开头,那么这也会影响子进程(反之亦然)。

  • 信号处理程序

  • 当前工作目录

  • 环境变量

有关更多详细信息,请参阅fork man page

子进程与父进程有什么不同?

进程 ID 是不同的。在子进程中调用getppid()(注意两个'p')将得到与在父进程中调用 getpid()相同的结果。有关更多详细信息,请参阅 fork man page。

我如何等待我的子进程完成?

使用waitpidwait。父进程将暂停,直到wait(或waitpid)返回。请注意,此解释忽略了重新启动的讨论。

fork-exec-wait 模式是什么

一个常见的编程模式是调用fork,然后是execwait。原始进程调用 fork,创建一个子进程。然后子进程使用 exec 来启动一个新程序的执行。与此同时,父进程使用wait(或waitpid)来等待子进程完成。请参阅下面的完整代码示例。

我如何启动一个同时运行的后台进程?

不要等待它们!您的父进程可以继续执行代码,而无需等待子进程。请注意,在实践中,通过在调用 exec 之前关闭打开的文件描述符,后台进程也可以与父进程的输入和输出流断开连接。

然而,在父进程完成之前完成的子进程可能会变成僵尸。有关更多信息,请参阅僵尸页面。

僵尸

好的父母不会让他们的孩子变成僵尸!

当一个子进程完成(或终止)时,它仍然占据内核进程表中的一个槽。只有在子进程被“等待”后,该槽才会再次可用。

一个长时间运行的程序可能会通过不断创建进程而永远不等待它们来创建许多僵尸。

太多僵尸会有什么影响?

最终,内核进程表中会没有足够的空间来创建新进程。因此,fork()会失败,并且可能使系统难以/无法使用 - 例如,仅登录就需要一个新进程!

系统如何帮助防止僵尸?

一旦一个进程完成,它的任何子进程都将被分配给“init” - 具有 pid 为 1 的第一个进程。因此,这些子进程将看到 getppid()返回值为 1。这些孤儿最终会完成,并在短暂的时刻成为僵尸。幸运的是,init 进程会自动等待它的所有子进程,从而将这些僵尸从系统中移除。

我如何防止僵尸?(警告:简化的答案)

等待你的孩子!

waitpid(child, &status, 0); // Clean up and wait for my child process to finish.

请注意,我们假设获得 SIGCHLD 事件的唯一原因是子进程已经完成(这并不完全正确 - 有关更多详细信息,请参阅 man page)。

一个健壮的实现还会检查中断状态,并在循环中包含上述内容。继续阅读,了解更健壮的实现的讨论。

我如何使用 SIGCHLD 异步等待我的子进程?(高级)

警告:本节使用了我们尚未完全介绍的信号。当子进程完成时,父进程会收到 SIGCHLD 信号,因此信号处理程序可以等待该进程。下面显示了一个稍微简化的版本。

pid_t child;

void cleanup(int signal) {
  int status;
  waitpid(child, &status, 0);
  write(1,"cleanup!\n",9);
}
int main() {
   // Register signal handler BEFORE the child can finish
   signal(SIGCHLD, cleanup); // or better - sigaction
   child = fork();
   if (child == -1) { exit(EXIT_FAILURE);}

   if (child == 0) { /* I am the child!*/
     // Do background stuff e.g. call exec 
   } else { /* I'm the parent! */
      sleep(4); // so we can see the cleanup
      puts("Parent is done");
   }
   return 0;
} 

然而,上面的例子忽略了一些微妙的地方:

  • 可能有多个子进程已经完成,但父进程只会收到一个 SIGCHLD 信号(信号不会排队)

  • SIGCHLD 信号可能是因为其他原因而发送的(例如,子进程暂时停止)

下面显示了一个更健壮的代码来清除僵尸进程。

void cleanup(int signal) {
  int status;
  while (waitpid((pid_t) (-1), 0, WNOHANG) > 0) {}
}

那么什么是环境变量?

环境变量是系统为所有进程保留的变量。您的系统现在已经设置了这些!在 Bash 中,您可以检查其中一些

$ echo $HOME
/home/bhuvy
$ echo $PATH
/usr/local/sbin:/usr/bin:... 

如何在 C/C++中获取这些?您可以使用getenvsetenv函数

char* home = getenv("HOME"); // Will return /home/bhuvy
setenv("HOME", "/home/bhuvan", 1 /*set overwrite to true*/ );

那么这些环境变量对父进程/子进程有什么意义呢?

每个进程都有自己的环境变量字典,这些变量会被复制到子进程中。这意味着,如果父进程更改其环境变量,它不会传递给子进程,反之亦然。如果您想要使用不同的环境变量执行程序,这在 fork-exec-wait 三部曲中很重要。

例如,您可以编写一个 C 程序,循环遍历所有时区,并执行date命令以打印出所有本地的日期和时间。环境变量用于各种程序,因此修改它们很重要。

进程控制,第一部分:等待宏,使用信号

等待宏

我可以找出我的子进程的退出值吗?

您可以找到子进程的最低 8 位退出值(main()的返回值或包含在exit()中的值):使用“等待宏” - 通常您将使用“WIFEXITED”和“WEXITSTATUS”。有关更多信息,请参阅wait/waitpid手册页。

int status;
pid_t child = fork();
if (child == -1) return 1; //Failed
if (child > 0) { /* I am the parent - wait for the child to finish */
  pid_t pid = waitpid(child, &status, 0);
  if (pid != -1 && WIFEXITED(status)) {
     int low8bits = WEXITSTATUS(status);
     printf("Process %d returned %d" , pid, low8bits);
  }
} else { /* I am the child */
 // do something interesting
  execl("/bin/ls", "/bin/ls", ".", (char *) NULL); // "ls ."
}

一个进程只能有 256 个返回值,其余的位是信息性的。

位移

请注意,没有必要记住这些,这只是信息存储在状态变量内部的高级概述

Android 源代码

/* 如果 WIFEXITED(STATUS),则为状态的低 8 位。 */
#define __WEXITSTATUS(status) (((status) & 0xff00) >> 8)
/* 如果 WIFSIGNALED(STATUS),则为终止信号。 */
#define __WTERMSIG(status) ((status) & 0x7f)
/* 如果 WIFSTOPPED(STATUS),则为停止子进程的信号。 */
#define __WSTOPSIG(status) __WEXITSTATUS(status)
/* 如果 STATUS 指示正常终止,则为非零。 */
#define __WIFEXITED(status) (__WTERMSIG(status) == 0)

内核有一种内部方式来跟踪已发出、已退出或已停止的信号。该 API 被抽象化,以便内核开发人员可以随意更改。

小心。

请记住,如果前提条件得到满足,宏才有意义。这意味着如果进程被发出信号,进程的退出状态将不会被定义。宏不会为您检查,因此需要编程确保逻辑正确。

信号

什么是信号?

信号是内核提供给我们的一种构造。它允许一个进程异步地向另一个进程发送信号(类似于消息)。如果该进程希望接受该信号,它可以,并且对于大多数信号,可以决定如何处理该信号。这里是一个信号的简短列表(非全面)。

名称默认操作通常用例
SIGINT终止进程(可以被捕获)告诉进程停止运行
SIGQUIT终止进程(可以被捕获)告诉进程停止运行
SIGSTOP停止进程(无法被捕获)停止进程以便继续
SIGCONT继续进程继续运行进程
SIGKILL终止进程(无法被忽略)你想让你的进程消失

我可以暂停我的子进程吗?

是的!您可以通过发送 SIGSTOP 信号临时暂停正在运行的进程。如果成功,它将冻结一个进程;即进程将不再分配任何 CPU 时间。

要允许进程恢复执行,请发送 SIGCONT 信号。

例如,这里有一个程序,每秒慢慢打印一个点,最多 59 个点。

#include <unistd.h>
#include <stdio.h>
int main() {
  printf("My pid is %d\n", getpid() );
  int i = 60;
  while(--i) { 
    write(1, ".",1);
    sleep(1);
  }
  write(1, "Done!",5);
  return 0;
}

我们首先将进程在后台启动(注意末尾的&)。然后通过使用 kill 命令从 shell 进程向其发送信号。

>./program &
My pid is 403
...
>kill -SIGSTOP 403
>kill -SIGCONT 403 

如何在 C 中杀死/停止/暂停我的子进程?

在 C 中,使用kill POSIX 调用向子进程发送信号,

kill(child, SIGUSR1); // Send a user-defined signal
kill(child, SIGSTOP); // Stop the child process (the child cannot prevent this)
kill(child, SIGTERM); // Terminate the child process (the child can prevent this)
kill(child, SIGINT); // Equivalent to CTRL-C (by default closes the process)

正如我们上面所看到的,在 shell 中也有一个 kill 命令,例如获取正在运行的进程列表,然后终止进程 45 和进程 46

ps
kill -l 
kill -9 45
kill -s TERM 46 

如何检测“CTRL-C”并优雅地清理?

我们稍后会回到信号 - 这只是一个简短的介绍。在 Linux 系统上,如果您有兴趣了解更多信息(例如系统和库调用的异步信号安全列表),请参阅man -s7 signal

信号处理程序内的可执行代码受到严格限制。大多数库和系统调用都不是“异步信号安全”的 - 它们不能在信号处理程序内使用,因为它们不是可重入安全的。在单线程程序中,信号处理会暂时中断程序执行,以执行信号处理程序代码。假设您的原始程序在执行malloc库代码时被中断;malloc 使用的内存结构将不处于一致状态。在信号处理程序中调用printf(它使用malloc)是不安全的,并将导致“未定义行为”,即它不再是一个有用的、可预测的程序。实际上,您的程序可能会崩溃、计算或生成不正确的结果,或者停止运行(“死锁”),具体取决于在执行信号处理程序代码时您的程序正在执行什么。

信号处理程序的一个常见用途是设置一个布尔标志,该标志偶尔被轮询(读取)作为程序正常运行的一部分。例如,

int pleaseStop ; // See notes on why "volatile sig_atomic_t" is better

void handle_sigint(int signal) {
  pleaseStop = 1;
}

int main() {
  signal(SIGINT, handle_sigint);
  pleaseStop = 0;
  while ( ! pleaseStop) { 
     /* application logic here */ 
   }
  /* cleanup code here */
}

上述代码在纸上看起来可能是正确的。然而,我们需要向编译器和将执行main()循环的 CPU 核心提供一个提示。我们需要防止编译器优化:表达式! pleaseStop似乎是一个循环不变量,即永远为真,因此可以简化为true。其次,我们需要确保pleaseStop的值不会被缓存在 CPU 寄存器中,而是始终从主存中读取和写入。sig_atomic_t类型意味着变量的所有位可以作为“原子操作”进行读取或修改 - 一个不可中断的操作。不可能读取由一些新位值和旧位值组成的值。

通过使用正确类型的volatile sig_atomic_t来指定pleaseStop,我们可以编写可移植的代码,其中主循环将在信号处理程序返回后退出。sig_atomic_t类型在大多数现代平台上可以与int一样大,但在嵌入式系统上可能只能表示(-127 至 127)的值,并且只能表示(-127 至 127)的值。

volatile sig_atomic_t pleaseStop;

这种模式的两个示例可以在“COMP”中找到,这是一个基于终端的 1Hz 4 位计算机(github.com/gto76/comp-cpp/blob/1bf9a77eaf8f57f7358a316e5bbada97f2dc8987/src/output.c#L121)。使用了两个布尔标志。一个用于标记SIGINT(CTRL-C)的传递,并优雅地关闭程序,另一个用于标记SIGWINCH信号以检测终端调整大小并重新绘制整个显示。

进程复习问题

主题

  • 正确使用 fork、exec 和 waitpid

  • 使用带有路径的 exec

  • 理解 fork、exec 和 waitpid 的作用。例如,如何使用它们的返回值。

  • SIGKILL 与 SIGSTOP 与 SIGINT。

  • 按下 CTRL-C 时发送了什么信号?

  • 从 shell 或 kill POSIX 调用使用 kill。

  • 进程内存隔离。

  • 进程内存布局(堆在哪里,栈等;无效的内存地址)。

  • 什么是 fork 炸弹、僵尸进程和孤儿进程?如何创建/删除它们。

  • getpid 与 getppid

  • 如何使用 WAIT 退出状态宏 WIFEXITED 等。

问题/练习

  • 带有 p 和不带 p 的 execs 有什么区别?操作系统是什么?

  • 如何将命令行参数传递给execl*execv*呢?按照惯例,第一个命令行参数应该是什么?

  • 如何知道execfork失败了?

  • 传递给 wait 的int *status指针是什么?wait 何时失败?

  • SIGKILLSIGSTOPSIGCONTSIGINT之间有哪些区别?默认行为是什么?哪些可以设置信号处理程序?

  • 按下CTRL-C时发送了什么信号?

  • 我的终端锚定在 PID = 1337,并且刚刚变得无响应。给我写一个终端命令和 C 代码,向其发送SIGQUIT

  • 一个进程能否通过正常手段改变另一个进程的内存?为什么?

  • 堆、栈、数据和文本段在哪里?哪些段可以写入?无效的内存地址是什么?

  • 用 C 语言编写一个 fork 炸弹(请不要运行它)。

  • 什么是孤儿进程?它如何变成僵尸进程?如何成为一个好的父进程?

  • 当父母告诉你不能做某事时,你是不是很讨厌?给我写一个程序,向你的父进程发送SIGSTOP

  • 编写一个 fork exec 等待可执行文件的函数,并使用等待宏告诉我进程是否正常退出或被信号中断。如果进程正常退出,则打印返回值。如果不是,则打印导致进程终止的信号编号。

三、内存和分配器

内存,第一部分:堆内存介绍

C 动态内存分配

当我调用 malloc 时会发生什么?

函数malloc是一个 C 库调用,用于保留一块连续的内存。与堆栈内存不同,内存保持分配状态,直到使用相同指针调用free。还有callocrealloc,下面将讨论它们。

malloc 可能失败吗?

如果malloc无法保留更多内存,则返回NULL。健壮的程序应该检查返回值。如果您的代码假设malloc成功,但实际上没有成功,那么当它尝试写入地址 0 时,您的程序很可能会崩溃(段错误)。

堆在哪里,有多大?

堆是进程内存的一部分,它没有固定的大小。当您调用malloccallocrealloc)和free时,C 库将执行堆内存分配。

首先快速回顾一下进程内存:进程是程序的运行实例。每个进程都有自己的地址空间。例如,在 32 位机器上,您的进程大约有 40 亿个地址可供使用,但并非所有这些地址都是有效的,甚至映射到实际的物理内存(RAM)。在进程的内存中,您将找到可执行代码、堆栈空间、环境变量、全局(静态)变量和堆。

通过调用sbrk,C 库可以根据程序对堆内存的需求增加堆的大小。由于堆和堆栈(每个线程一个)需要增长,我们将它们放在地址空间的相对两端。因此,对于典型的架构,堆将向上增长,堆栈向下增长。

真相:现代操作系统内存分配器不再需要sbrk-相反,它们可以请求独立的虚拟内存区域并维护多个内存区域。例如,大量请求可以放置在与小分配请求不同的内存区域中。但是,这个细节是一个不需要的复杂性:碎片化和有效分配内存的问题仍然存在,因此我们将忽略这个实现细节,并将其写成堆是一个单一区域的样子。

如果我们编写一个多线程程序(稍后会详细介绍),我们将需要多个堆栈(每个线程一个),但只有一个堆。

在典型的架构中,堆是数据段的一部分,它从代码和全局变量的上方开始。

程序需要调用 brk 或 sbrk 吗?

通常不需要(尽管调用sbrk(0)可能会很有趣,因为它告诉您堆当前的结束位置)。相反,程序使用malloc,calloc,reallocfree,它们是 C 库的一部分。当需要额外的堆内存时,这些函数的内部实现将调用sbrk

void *top_of_heap = sbrk(0);
malloc(16384);
void *top_of_heap2 = sbrk(0);
printf("The top of heap went from %p to %p \n", top_of_heap, top_of_heap2);

示例输出:堆的顶部从 0x4000 到 0xa000

什么是 calloc?

malloc不同,calloc将内存内容初始化为零,并且还接受两个参数(项目的数量和每个项目的字节大小)。一个朴素但可读的calloc实现如下:

void *calloc(size_t n, size_t size)
{
    size_t total = n * size; // Does not check for overflow!
    void *result = malloc(total);

    if (!result) return NULL;

// If we're using new memory pages 
// just allocated from the system by calling sbrk
// then they will be zero so zero-ing out is unnecessary,

    memset(result, 0, total);
    return result; 
}

有关这些限制的高级讨论在这里

程序员通常使用calloc而不是在malloc后显式调用memset,以将内存内容设置为零。请注意,calloc(x,y)calloc(y,x)相同,但您应该遵循手册的约定。

// Ensure our memory is initialized to zero
link_t *link  = malloc(256);
memset(link, 0, 256); // Assumes malloc returned a valid address!

link_t *link = calloc(1, 256); // safer: calloc(1, sizeof(link_t));

为什么 sbrk 首次返回的内存初始化为零?

如果操作系统没有清零物理 RAM 的内容,可能会导致一个进程了解到先前使用过该内存的另一个进程的内存。这将是一个安全漏洞。

不幸的是,这意味着对于在释放任何内存之前进行的malloc请求和简单程序(最终使用系统中新保留的内存)来说,内存通常是零。然后程序员错误地编写了假设 malloc'd 内存将始终为零的 C 程序。

char* ptr = malloc(300);
// contents is probably zero because we get brand new memory
// so beginner programs appear to work!
// strcpy(ptr, "Some data"); // work with the data
free(ptr);
// later
char *ptr2 = malloc(308); // Contents might now contain existing data and is probably not zero

为什么 malloc 不总是将内存初始化为零?

性能!我们希望 malloc 尽可能快。清零内存可能是不必要的。

realloc 是什么,什么时候会用到它?

realloc允许你调整之前通过 malloc、calloc 或 realloc 在堆上分配的现有内存分配的大小。realloc 最常见的用途是调整用于保存值数组的内存。下面建议一个朴素但可读的 realloc 版本

void * realloc(void * ptr, size_t newsize) {
  // Simple implementation always reserves more memory
  // and has no error checking
  void *result = malloc(newsize); 
  size_t oldsize =  ... //(depends on allocator's internal data structure)
  if (ptr) memcpy(result, ptr, newsize < oldsize ? newsize : oldsize);
  free(ptr);
  return result;
}

下面显示了 realloc 的错误用法:

int *array = malloc(sizeof(int) * 2);
array[0] = 10; array[1] = 20;
// Ooops need a bigger array - so use realloc..
realloc (array, 3); // ERRORS!
array[2] = 30; 

上面的代码包含两个错误。首先,我们需要 3*sizeof(int)字节,而不是 3 字节。其次,realloc 可能需要将内存的现有内容移动到新位置。例如,可能没有足够的空间,因为相邻的字节已经被分配。下面显示了 realloc 的正确用法。

array = realloc(array, 3 * sizeof(int));
// If array is copied to a new location then old allocation will be freed.

一个健壮的版本还会检查NULL返回值。注意realloc可以用来增加和缩小分配。

我在哪里可以读到更多信息?

参见man 页面

内存分配的速度有多重要?

非常重要!在大多数应用程序中,分配和释放堆内存是常见操作。

分配简介

最愚蠢的 malloc 和 free 实现是什么,有什么问题?

void* malloc(size_t size)
// Ask the system for more bytes by extending the heap space. 
// sbrk Returns -1 on failure
   void *p = sbrk(size); 
   if(p == (void *) -1) return NULL; // No space left
   return p;
}
void free() {/* Do nothing */}

上述实现遭受两个主要缺点:

  • 系统调用很慢(与库调用相比)。我们应该保留大量内存,只偶尔向系统请求更多。

  • 不重用释放的内存。我们的程序从不重用堆内存-它只是不断地要求更大的堆。

如果这个分配器在一个典型的程序中使用,进程将很快耗尽所有可用的内存。相反,我们需要一个能够有效利用堆空间,并且只在必要时请求更多内存的分配器。

什么是放置策略?

在程序执行期间,内存被分配和释放,因此堆内存中会有间隙(空洞),可以重新用于未来的内存请求。内存分配器需要跟踪堆的哪些部分当前被分配,哪些部分是可用的。

假设我们当前的堆大小是 64K,尽管并非所有都在使用,因为一些先前通过程序释放的 malloc 内存已经被释放了:

16KB free10KB allocated1KB free1KB allocated30KB free4KB allocated2KB free

如果执行一个新的 2KB 的 malloc 请求(malloc(2048)),malloc 应该在哪里保留内存?它可以使用最后的 2KB 空洞(恰好是完美的大小!),或者它可以分割其他两个空闲空洞中的一个。这些选择代表不同的放置策略。

无论选择哪个空洞,分配器都需要将空洞分成两部分:新分配的空间(将返回给程序)和一个较小的空洞(如果有剩余空间)。

完美拟合策略找到足够大的最小空洞(至少 2KB):

16KB free10KB allocated1KB free1KB allocated30KB free4KB allocated2KB HERE!

最坏的拟合策略找到足够大的最大空洞(所以将 30KB 的空洞分成两部分):

16KB free10KB allocated1KB free1KB allocated2KB HERE!28KB free4KB allocated2KB free

第一个适合策略找到第一个足够大的可用空洞(将 16KB 的空洞分成两部分):

2KB HERE!14KB free10KB allocated1KB free1KB allocated30KB free4KB allocated2KB free

什么是外部碎片?

在下面的例子中,64KB 的堆内存中,有 17KB 被分配,47KB 是空闲的。然而,最大的可用块只有 30KB,因为我们的可用未分配堆内存被分成了更小的块。

16KB free10KB allocated1KB free1KB allocated30KB free4KB allocated2KB free

放置策略对外部碎片和性能有什么影响?

不同的策略以不明显的方式影响堆内存的碎片化,这只能通过数学分析或在真实条件下进行仔细模拟(例如模拟数据库或 Web 服务器的内存分配请求)来发现。例如,最佳适配乍看起来似乎是一个很好的策略,但是,如果我们找不到一个完全大小合适的空洞,那么这种放置会产生许多微小的无法使用的空洞,导致高度碎片化。它还需要扫描所有可能的空洞。

首次适配的优势在于它不会评估所有可能的放置,因此更快。

由于最坏适配针对最大的未分配空间,如果需要大量分配,则这是一个不好的选择。

在实践中,首次适配和下次适配(这里没有讨论)通常是常见的放置策略。还有混合方法和许多其他选择(请参见实现内存分配器页面)。

编写堆分配器的挑战是什么?

主要挑战是,

  • 需要最小化碎片化(即最大化内存利用)

  • 需要高性能

  • 繁琐的实现(使用链表和指针算术进行大量指针操作)

一些额外的评论:

碎片化和性能都取决于应用程序的分配配置文件,这可以进行评估但无法预测,并且在实践中,在特定的使用条件下,专用分配器通常可以胜过通用实现。

分配器事先不知道程序的内存分配请求。即使我们知道,这也是著名的 NP 难题背包问题

如何实现内存分配器?

好问题。实现内存分配器

内存,第二部分:实现内存分配器

内存分配器教程

内存分配器需要跟踪哪些字节当前已分配,哪些可供使用。本页介绍了构建分配器的实现和概念细节,即实现mallocfree的实际代码。

这个页面讨论了块的链接 - 我应该为它们分配内存吗?

尽管在概念上我们考虑创建链接列表和块列表,但我们不需要“malloc 内存”来创建它们!相反,我们将整数和指针写入我们已经控制的内存中,以便以后可以一致地从一个地址跳到下一个地址。这些内部信息代表了一些开销。因此,即使我们从系统请求了 1024 KB 的连续内存,我们也无法将所有内存提供给运行的程序。

块思考

我们可以将我们的堆内存看作是一个块的列表,其中每个块都是已分配或未分配的。我们不是存储一个显式的指针列表,而是存储关于块大小的信息作为块的一部分。因此,在概念上,有一个空闲块的列表,但它是隐式的,即以每个块的大小信息的形式存储。

我们可以通过添加块的大小来从一个块导航到下一个块。例如,如果您有一个指向块起始位置的指针p,那么next_block将在((char *)p) + *(size_t *) p,如果您将块的大小以字节存储。将char *强制转换为确保指针算术是以字节计算的。将size_t *强制转换为确保在p处读取的内存是一个大小值,如果pvoid *char *类型,则必须。

调用程序永远不会看到这些值;它们是内存分配器实现的内部值。

例如,假设您的分配器被要求保留 80 字节(malloc(80))并需要 8 字节的内部头数据。分配器需要找到至少 88 字节的未分配空间。在更新堆数据后,它将返回一个指向该块的指针。但是,返回的指针并不指向块的起始位置,因为那里存储着内部大小数据!相反,我们将返回块的起始位置+8 字节。在实现中,记住指针算术取决于类型。例如,p += 8添加的是8 * sizeof(p),而不一定是 8 字节!

实现 malloc

最简单的实现使用首次适配:从第一个块开始,假设存在,迭代直到找到表示足够大小的未分配空间的块,或者我们已经检查了所有的块。

如果找不到合适的块,现在是调用 sbrk()的时候了,以充分扩展堆的大小。一个快速的实现可能会显著地扩展它,这样我们在不久的将来就不需要再请求更多的堆内存。

当找到一个空闲块时,它可能比我们需要的空间大。如果是这样,我们将在我们的隐式列表中创建两个条目。第一个条目是已分配的块,第二个条目是剩余的空间。

有两种简单的方法可以确定块是否正在使用或可用。第一种是将其存储为头信息中的一个字节,以及大小的最低位编码为 1!因此,块大小信息将仅限于偶数值:

// Assumes p is a reasonable pointer type, e.g. 'size_t *'.
isallocated = (*p) & 1;
realsize = (*p) & ~1;  // mask out the lowest bit 

对齐和向上取整的考虑

许多体系结构希望多字节原语对齐到 2^n 的某个倍数。例如,通常要求 4 字节类型对齐到 4 字节边界(64 位系统上的 8 字节类型对齐到 8 字节边界)。如果多字节原语未存储在合理的边界上(例如从奇数地址开始),则性能可能会受到显着影响,因为可能需要两个内存读取请求而不是一个。在某些体系结构上,惩罚甚至更大-程序将因总线错误而崩溃。

由于malloc不知道用户将如何使用分配的内存(双精度数组?字符数组?),因此返回给程序的指针需要对最坏情况进行对齐,这取决于体系结构。

根据 glibc 文档,glibc malloc使用以下启发式方法:“malloc 给您的块保证对齐,以便它可以容纳任何类型的数据。在 GNU 系统上,大多数系统的地址始终是 8 的倍数,在 64 位系统上是 16 的倍数。”

例如,如果您需要计算需要多少个 16 字节单位,请不要忘记四舍五入-

int s = (requested_bytes + tag_overhead_bytes + 15) / 16 

附加的常数确保不完整的单元被四舍五入。请注意,实际代码更有可能使用符号大小,例如sizeof(x) - 1,而不是编码数值常数 15。

如果您有进一步兴趣,这是一篇关于内存对齐的好文章

关于内部碎片的说明

内部碎片发生在您提供的块大于其分配大小时。假设我们有一个大小为 16B 的空闲块(不包括元数据)。如果它们分配了 7 个字节,您可能希望将其四舍五入为 16B 并返回整个块。

当您实现合并和分割时(下一节),情况会变得非常阴险。如果您两者都不实现,那么您可能会为 7B 的分配返回一个大小为 64B 的块!这种分配会产生大量的开销,而我们正试图避免这种情况。

实施释放

当调用free时,我们需要重新应用偏移量以返回到块的“真实”起始位置(记住我们没有给用户指向块实际起始位置的指针?),即我们存储大小信息的位置。

一个天真的实现只会将块标记为未使用。如果我们将块分配状态存储在最低大小位中,那么我们只需要清除该位:

*p = (*p) & ~1; // Clear lowest bit 

然而,我们还有更多的工作要做:如果当前块和下一个块(如果存在)都是空闲的,我们需要将这些块合并成一个单一的块。同样,我们也需要检查前一个块。如果存在并表示未分配的内存,那么我们需要将这些块合并成一个单一的大块。

为了能够将一个空闲块与前一个空闲块合并,我们还需要找到前一个块,因此我们也将块的大小存储在块的末尾。这些被称为“边界标记”(参考 Knuth73)。由于块是连续的,一个块的末尾就紧邻着下一个块的开始。因此,当前块(除了第一个块)可以向后查找几个字节以查找前一个块的大小。有了这些信息,您现在可以向后跳转了!

性能

有了上述描述,就可以构建一个内存分配器。它的主要优势是简单性 - 至少与其他分配器相比是简单的!分配内存是最坏情况下的线性时间操作(搜索链表以找到足够大的空闲块),而释放是常数时间(最多只需要将 3 个块合并成一个块)。使用这个分配器,可以尝试不同的放置策略。例如,可以从上次释放块的位置开始搜索,或者从上次分配的位置开始搜索。如果您存储块的指针,您需要非常小心,确保它们始终保持有效(例如,在合并块或其他更改堆结构的 malloc 或 free 调用时)。

显式空闲列表分配器

通过实现一个显式的双向链表可以实现更好的性能。在这种情况下,我们可以立即遍历到下一个空闲块和上一个空闲块。这可以减半搜索时间,因为链表只包括未分配的块。

第二个优势是我们现在对链表的排序有一定的控制。例如,当一个块被释放时,我们可以选择将其插入到链表的开头,而不总是在其邻居之间。这将在下面讨论。

我们在哪里存储链表的指针?一个简单的技巧是意识到块本身没有被使用,并将下一个和上一个指针存储为块的一部分(尽管现在你必须确保空闲块始终足够大,以容纳两个指针)。

我们仍然需要实现边界标签(即使用大小的隐式列表),以便我们可以正确地释放块并将它们与它们的两个邻居合并。因此,显式空闲列表需要更多的代码和复杂性。

使用显式链表,使用快速简单的“查找第一个”算法来查找第一个足够大的链接。然而,由于链接顺序可以被修改,这对应于不同的放置策略。例如,如果链接从大到小维护,那么这将产生“最坏适合”放置策略。

显式链表插入策略

新释放的块可以轻松地插入到两个可能的位置:在开头或按地址顺序(通过使用边界标签首先找到邻居)。

在开头插入会创建一个 LIFO(后进先出)策略:最近释放的空间将被重复使用。研究表明,碎片化比使用地址顺序更严重。

按地址顺序插入(“按地址顺序策略”)插入释放的块,以便以递增的地址顺序访问块。这种策略需要更多的时间来释放块,因为必须使用边界标签(大小数据)来找到下一个和上一个未分配的块。然而,碎片化较少。

案例研究:Buddy Allocator(分离列表的一个示例)

分离的分配器是将堆分成由不同子分配器处理的不同区域的分配器,这取决于分配请求的大小。大小被分组为类(例如,2 的幂),每个大小由不同的子分配器处理,每个大小维护其自己的空闲列表。

这种类型的一个众所周知的分配器是伙伴分配器。我们将讨论二进制伙伴分配器,它将分配分成 2^n(n = 1, 2, 3,...)倍一些基本单位字节数的块,但也存在其他类型(例如,斐波那契分割 - 你能看出为什么它被命名了吗?)。基本概念很简单:如果没有大小为 2^n 的空闲块,就转到下一个级别并窃取该块并将其分成两个。如果两个相邻的相同大小的块变为未分配状态,则它们可以合并成一个两倍大小的单个大块。

伙伴分配器之所以快速,是因为可以从释放的块的地址计算出要合并的相邻块,而不是遍历大小标签。最终的性能通常需要少量的汇编代码来使用专门的 CPU 指令来找到最低的非零位。

伙伴分配器的主要缺点是它们受到内部碎片的影响,因为分配被舍入到最近的块大小。例如,68 字节的分配将需要一个 128 字节的块。

进一步阅读和参考资料

其他分配器

有许多其他分配方案。例如SLUB(维基百科)- Linux 内核内部使用的三种分配器之一。

内存,第三部分:破坏堆栈示例

每个线程使用堆栈内存。堆栈“向下增长” - 如果一个函数调用另一个函数,那么堆栈会扩展到更小的内存地址。堆栈内存包括非静态自动(临时)变量,参数值和返回地址。如果缓冲区太小,一些数据(例如来自用户的输入值),那么其他堆栈变量甚至返回地址可能会被覆盖。堆栈内容的精确布局和自动变量的顺序取决于体系结构和编译器。然而,通过一些调查工作,我们可以学会如何故意破坏特定体系结构的堆栈。

下面的示例演示了返回地址存储在堆栈上的方式。对于特定的 32 位体系结构Live Linux Machine,我们确定返回地址存储在自动变量地址的两个指针(8 字节)以上的地址。代码故意改变堆栈值,以便当输入函数返回时,不是继续在主方法内部进行,而是跳转到利用函数。

// Overwrites the return address on the following machine:
// http://cs-education.github.io/sys/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void breakout() {
    puts("Welcome. Have a shell...");
    system("/bin/sh");
}
void input() {
  void *p;
  printf("Address of stack variable: %p\n", &p);
  printf("Something that looks like a return address on stack: %p\n", *((&p)+2));
  // Let's change it to point to the start of our sneaky function.
  *((&p)+2) = breakout;
}
int main() {
    printf("main() code starts at %p\n",main);

    input();
    while (1) {
        puts("Hello");
        sleep(1);
    }

    return 0;
}

计算机通常有很多种方法来解决这个问题。

内存复习问题

主题

  • 最佳适配

  • 最差适配

  • 首次适配

  • 伙伴分配器

  • 内部碎片

  • 外部碎片

  • sbrk

  • 自然对齐

  • 边界标签

  • 合并

  • 分割

  • Slab 分配/内存池

问题/练习

  • 什么是内部碎片?它何时成为一个问题?

  • 什么是外部碎片?它何时成为一个问题?

  • 什么是最佳适配策略?它与外部碎片有什么关系?时间复杂度是多少?

  • 什么是最差适配策略?它在外部碎片方面有所改善吗?时间复杂度是多少?

  • 什么是首次适配放置策略?它在碎片方面稍微好一点,对吧?预期时间复杂度是多少?

  • 假设我们正在使用一个新的 64kb 大小的伙伴分配器。它如何分配 1.5kb?

  • 当 5 行sbrk实现 malloc 时有什么用处?

  • 自然对齐是什么?

  • 什么是合并/分割?它们如何增加/减少碎片?何时可以合并或分割?

  • 边界标签是如何工作的?它们如何用于合并或分割?