[笔记]Windows核心编程《十六》线程栈

简介: [笔记]Windows核心编程《十六》线程栈

前言

系统会在用户进程的地址控件中预定区域情况:

  • 分配进程环境块
  • 分配线程环境块

当系统创建线程时,会为线程栈预定一块地址空间区域(每个线程都有自己的栈)并给区域调拨一些物理存储器。

默认情况系统会预定1MB的地址空间并调拨两个页的存储器。

(进程最大地址空间大小Windows平台似乎是2G)

开发人员可以通过两种方式改变该默认值:

  • 可以通过MSVC++编译器的/F选项
  • 使用MSVC++ 链接器的/STACK选项:/Freserve /STACK:reserve[,commit]

链接器会将想要的栈大小写入exe或dll的PE文件头中。系统要创建线程栈的时候会根据PE文件头的大小类预定地址空间。

但是在调用CreateThread或_beginthreadexh函数也可以指定需要在一开始调拨的存储器数量(即栈初始大小)。这两个函数都有一个参数,可以用来指定调拨给线程栈的地址空间区域的存储器大小,如果传递0则使用PE文件头中默认定义的大小。后面我们假设都是使用的默认值:即区域大小为1MB,每次调拨一个存储页面。

线程栈变化的几种情况

16-1 线程栈的地址空间区域最初创建的样子

在基地址0x08000000 上预定的栈空间。

栈地址空间和所有已经调拨的物理存储器都具有PAGE_READWRITE保护属性。

系统会给最高地址(顶部)的两个页面调拨物理存储器。

区域顶部往下的第二个页面被称为 防护页面(guard page) 随着线程调用越来越多的函数,调用树也越来越深,线程也需要越来越多的栈空间。

当线程试图访问防护页面中的内存时,系统会得到通知。

系统会给防护页下面的那个页面调拨存储器 ,接着除去当前防护页面的PAGE_GUARD保护属性,然后给刚调拨的存储页指定PAGE_GUARD保护属性。

如果线程栈不断加深,那么栈的地址空间区域看起来像16-2

栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也可能是1M,它是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小

堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

参考来源:https://blog.csdn.net/willib/article/details/21086207

16-2 即将用尽的栈地址空间区域

当调用接近栈底的时候,系统会给栈底的前的最后一个页面调拨物理存储器。同时去除其PAGE_GUARD保护属性。

但是此时系统不会再给栈底的页面调拨物理存储器并设置PAGE_GUARD 如16-3。

16-3 已用尽的栈地址空间区域

当线程访问最后一个已预定的页面 系统抛出EXCEPTION_STACK_OVERFLOW异常

当系统给0x0800100的页面调拨物理存储器的时候(即最后一个已预定的页面时),会执行一个额外操作-抛出EXCEPTION_STACK_OVERFLOW 值为0xC00000FD 可以使用SEH来捕获并处理。

当线程继续访问未预定页面时 系统会抛出访问违规异常

如果线程在引发栈异常以后继续使用栈,会用尽0x8001000的页面中的内存,并试图访问0x8000000页面中的内存。

当线程试图访问未预定的页面,系统会抛出访问违规异常。

此时控制权会交给Windows Error Reporting service 并弹出一个框,然后结束当前进程。

处理STACK_OVERFLOW办法是使用SetThreadStackGuarante函数

为了避免该情况应该调用SetThreadStackGuarante函数,来抛出EXCEPTION_STACK_OVERFLOW 可以确保在Windows错误报告服务接管并终止进程之前,地址空间还有指定数量的内存可以使用,这使得应用程序能处理栈异常并恢复运行。

当线程访问最后一个防护页面时,系统会抛出EXCEPTION_STACK_oVERFLOW异常。如果线程捕获了该异常并继续执行,那么系统将不会在同一个线程中再次抛出EXCEPTION_STACK_OVERFLOW异常,这是因为后面再也没有防护页面了.

如果希望在同一线程中继续收到EXCEPTION_STACK_OVERFLOW异常,那么应用程序必须重置防护页面。这很容易办到,只需调用C运行库的_resetstkoflw 函数(在malloc.h中定义).

为什么系统始终不给栈地址空间最底部的页面调拨物理存储器?

这样做的目的是为了保护进程使用的其他数据,使它们不会因为意外的内存写越界而遭到破坏。

栈下溢(stack underflow)

看下面代码:

int WINPAI WinMain(HINSTANCE hInstExe,HINSTANCE,PTSTRpszCmdLine,int nCmdShow){
  BYTE aBytes[100];
  aBytes[10000] = 0;//Stack Underflow
  return 0;
}

代码试图访问线程栈之外的内存,编译器和链接器无法发现这类错误。

这条语句可能会引发访问违规,也可能不会, 因为紧接着线程栈后面可能有另一块已调拨的地址空间区域。如果发生这种情况,程序可能会破坏属于进程的另一部分内存,而系统是无法检测到这种错误的。

偶然间 我也遇到过这种破坏内存的情况,使用memset时初始化内存区域算错。导致破坏内存,随机崩溃。这种例子不胜枚举。

一、C/C++运行库的栈检查函数

C++运行库有一个栈检查函数。在编译源代码的时候,编译器会在必要的时候生成代码来调用该函数。该函数的目的是确保已经给线程栈调拨了物理存储器。

void SomeFunction() {
  int nValues[4000];
  // Do some processing with the array.
  nValues[0] = 0; // Some assignment
}

该函数至少需要16000字节(4000x sizeof(int))通常情况下编译器生成的用来分配栈空间的代码会直接把cpu的指针减去16000字节。除非程序试图访问其中的数据,否则系统是不会给这块区域调拨物理存储器的。

在页面大小为4KB或8KB的系统中,这个限制可能会产生问题。如果第一次访问的地址要低于防护页面(例如前面的赋值语句所示),线程会访问尚未调拨的内存并引发访问违规。为了确保开发人员编写的类似代码能够正常运行,编译器需要插入一些代码来调用C运行库的检查函数。编译器知道目标系统的页面大小,并且在处理程序的每个函数时,能算出函数需要多大的栈空间。如果需要的占空间大于目标系统的页面大小,编译器会自动插入代码来调用检查函数。

以下伪代码展示了检查函数到底做了什么事。(通常由编译器使用汇编语言来实现)

// The C run-time library knows the page size for the target system.
#ifdef _M_IA64
#define PAGESIZE (8 * 1024)   // 8-KB page
#else
#define PAGESIZE (4 * 1024)   // 4-KB page
#endif
void StackCheck(int nBytesNeededFromStack) {
  // Get the stack pointer position.
  // At this point, the stack pointer has NOT been decremented
  // to account for the function's local variables.
  PBYTE pbStackPtr = (CPU's sack pointer);
  //
  while (nBytesNeededFromStack >= PAGESIZE) {
    // Move down a page on the stack-- should be a gurad page.
    pbStackPtr -= PAGESIZE;
    // Access a byte on the gurad page--force new page to be
    // committed and gurad page to move down a page.
    pbStackPtr[0] = 0;
    // Reduce the number of bytes needed from the stack.
    nBytesNeededFromStack -= PAGESIZE;
  }
  // Before returning, the StackCheck function sets the CPU's
  // stack pointer to the address below the function's
  // local variables.
}

编译器会根据目标平台的页面大小自动插入代码来调用StackCheck。为了让开发人员对所使用页面大小的阈值进行控制,VC++提供了/Gs编译器开关。详细参考MSDN

二、Summation示例程序

演示如何使用SEH处理程序来使的程序从栈溢出中的得体的恢复并继续运行。

源码地址

总结

  1. 访问越界和栈溢出异常,栈溢出由于访问到保护页面,所以会抛出栈溢出异常,如果继续访问会抛出访问越界异常;栈下溢不一定导致访问越界,取决于访问空间是否调拨了,调拨了则不会导致,反之。上周遇到memset初始化时 地址空间算错,导致随机崩溃。
  2. 堆:堆是向高地址扩展的数据结构
  3. 栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。
  4. 线程栈的大小是由可以由编译器参数和链接器参数设置的,一般Windows 1M或2M。
  5. 系统不给栈地址空间最底部的页面调拨物理存储器是为了保护进程使用的其他数据,使它们不会因为意外的内存写越界而遭到破坏。

相关:

参考部分sesiria大佬的《Windows核心编程》读书笔记十六 线程栈

进程线程及堆栈之间内存分配和关系总结

windows内存结构概述(转)


相关文章
|
8天前
|
消息中间件 存储 缓存
【嵌入式软件工程师面经】Linux系统编程(线程进程)
【嵌入式软件工程师面经】Linux系统编程(线程进程)
20 1
|
15天前
|
Java 开发者 UED
掌握Java多线程编程:从基础到高级
【5月更文挑战第31天】本文深入探讨了Java多线程编程的核心概念,包括线程的创建、生命周期、同步机制以及高级并发工具。通过实际示例和代码片段,读者将学会如何有效地管理和协调线程,以编写高效且稳定的并发应用程序。
|
15天前
|
安全 Java 调度
Java语言多线程编程技术深度解析
Java语言多线程编程技术深度解析
288 1
|
16天前
|
存储 Java 程序员
C++多线程编程基础
C++多线程编程基础
27 2
|
16天前
|
存储 安全 Java
深入理解Java并发编程:线程安全与锁机制
【5月更文挑战第31天】在Java并发编程中,线程安全和锁机制是两个核心概念。本文将深入探讨这两个概念,包括它们的定义、实现方式以及在实际开发中的应用。通过对线程安全和锁机制的深入理解,可以帮助我们更好地解决并发编程中的问题,提高程序的性能和稳定性。
|
2天前
|
存储 安全 Java
Java多线程编程--JUC
Java多线程编程
|
7天前
|
安全 开发者 Python
Python中的多线程与多进程编程
Python作为一种广泛使用的编程语言,在处理并发性能时具有独特的优势。本文将深入探讨Python中的多线程与多进程编程技术,分析其原理和应用,帮助读者更好地理解并发编程在Python中的实现与优化。
|
8天前
|
Java 调度 数据库
Java中的多线程编程:基础与实践
【6月更文挑战第7天】本文将深入探讨Java中的多线程编程,包括其基本概念、创建方法以及在实际应用中的一些技巧。我们将通过实例来展示如何在Java中有效地使用多线程,以提高程序的性能和响应能力。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的见解。
|
9天前
|
安全 Java 调度
Java中的多线程编程:基础与实践
【6月更文挑战第6天】本文将探讨Java中的多线程编程,包括其基本概念、创建线程的方法以及线程的同步和通信。我们将通过实例来理解多线程编程的重要性,并学习如何在实际项目中应用这些知识。
|
9天前
|
安全 Java 开发者
Java并发编程的艺术:解锁多线程同步的奥秘
本文将深入探讨Java并发编程的核心概念,揭示多线程环境下同步机制的工作原理与实践技巧。我们将从基础的synchronized关键字讲起,逐步过渡到高级的Lock接口和并发工具类,最后通过实例分析来加深理解。文章不仅旨在为初学者提供一个清晰的并发编程入门指南,同时也希望能够帮助有一定经验的开发者巩固和提升他们的并发处理能力。