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

简介: Windows核心编程《十六》线程栈

系列文章目录



[笔记]Windows核心编程《一》错误处理、字符编码

[笔记]Windows核心编程《二》内核对象

[笔记]Windows核心编程《三》进程

[笔记]Windows核心编程《四》作业

[笔记]快乐的LInux命令行《五》什么是shell

[笔记]Windows核心编程《五》线程基础

[笔记]Windows核心编程《六》线程调度、优先级和关联性

[笔记]Windows核心编程《七》用户模式下的线程同步

[笔记]Windows核心编程《八》用内核对象进行线程同步

[笔记]Windows核心编程《九》同步设备I/O和异步设备I/O

[笔记]Windows核心编程《十一》Windows线程池

[笔记]Windows核心编程《十二》纤程

[笔记]Windows核心编程《十三》windows内存体系结构

[笔记]Windows核心编程《十四》探索虚拟内存

[笔记]Windows核心编程《十五》在应用程序中使用虚拟内存

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

[笔记]Windows核心编程《十七》内存映射文件

[笔记]Windows核心编程《十八》堆栈

[笔记]Windows核心编程《十九》DLL基础

[笔记]Windows核心编程《二十》DLL的高级操作技术

[笔记]Windows核心编程《二十一》线程本地存储器TLS

[笔记]Windows核心编程《二十二》注入DLL和拦截API

[笔记]Windows核心编程《二十三》结构化异常处理


文章目录



   系列文章目录

   前言

       线程栈变化的几种情况

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

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

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

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

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

               处理STACK_OVERFLOW办法是使用SetThreadStackGuarante函数

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

           栈下溢(stack underflow)

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

   二、Summation示例程序

   总结


前言


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

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


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

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

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


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


   可以通过MSVC++编译器的/F选项

   使用MSVC++ 链接器的/STACK选项:/Freserve /STACK:reserve[,commit]


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


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


线程栈变化的几种情况

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

图片.png

在基地址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 即将用尽的栈地址空间区域

图片.png


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

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


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

图片.png

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


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

图片.png

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


如果线程在引发栈异常以后继续使用栈,会用尽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

图片.png


二、Summation示例程序


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

源码地址


总结


   1.访问越界和栈溢出异常,栈溢出由于访问到保护页面,所以会抛出栈溢出异常,如果继续访问会抛出访问越界异常;栈下溢不一定导致访问越界,取决于访问空间是否调拨了,调拨了则不会导致,反之。上周遇到memset初始化时 地址空间算错,导致随机崩溃。

  2. 堆:堆是向高地址扩展的数据结构

  3. 栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。

   4.线程栈的大小是由可以由编译器参数和链接器参数设置的,一般Windows 1M或2M。

   5.系统不给栈地址空间最底部的页面调拨物理存储器是为了保护进程使用的其他数据,使它们不会因为意外的内存写越界而遭到破坏。


相关:

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

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

windows内存结构概述(转)

相关文章
|
2月前
|
Java 开发者
Java多线程编程中的常见误区与最佳实践####
本文深入剖析了Java多线程编程中开发者常遇到的几个典型误区,如对`start()`与`run()`方法的混淆使用、忽视线程安全问题、错误处理未同步的共享变量等,并针对这些问题提出了具体的解决方案和最佳实践。通过实例代码对比,直观展示了正确与错误的实现方式,旨在帮助读者构建更加健壮、高效的多线程应用程序。 ####
|
2月前
|
安全 Java UED
深入浅出Java多线程编程
【10月更文挑战第40天】在Java的世界中,多线程是提升应用性能和响应能力的关键。本文将通过浅显易懂的方式介绍Java中的多线程编程,从基础概念到高级特性,再到实际应用案例,带你一步步深入了解如何在Java中高效地使用多线程。文章不仅涵盖了理论知识,还提供了实用的代码示例,帮助你在实际开发中更好地应用多线程技术。
57 5
|
13天前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
93 2
|
2月前
|
设计模式 Java 开发者
Java多线程编程的陷阱与解决方案####
本文深入探讨了Java多线程编程中常见的问题及其解决策略。通过分析竞态条件、死锁、活锁等典型场景,并结合代码示例和实用技巧,帮助开发者有效避免这些陷阱,提升并发程序的稳定性和性能。 ####
|
1月前
|
缓存 Java 调度
多线程编程核心:上下文切换深度解析
在现代计算机系统中,多线程编程已成为提高程序性能和响应速度的关键技术。然而,多线程编程中一个不可避免的概念就是上下文切换(Context Switching)。本文将深入探讨上下文切换的概念、原因、影响以及优化策略,帮助你在工作和学习中深入理解这一技术干货。
47 10
|
2月前
|
缓存 Java 开发者
Java多线程编程的陷阱与最佳实践####
本文深入探讨了Java多线程编程中常见的陷阱,如竞态条件、死锁和内存一致性错误,并提供了实用的避免策略。通过分析典型错误案例,本文旨在帮助开发者更好地理解和掌握多线程环境下的编程技巧,从而提升并发程序的稳定性和性能。 ####
|
29天前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
29天前
|
Java 调度
Java中的多线程编程与并发控制
本文深入探讨了Java编程语言中多线程编程的基础知识和并发控制机制。文章首先介绍了多线程的基本概念,包括线程的定义、生命周期以及在Java中创建和管理线程的方法。接着,详细讲解了Java提供的同步机制,如synchronized关键字、wait()和notify()方法等,以及如何通过这些机制实现线程间的协调与通信。最后,本文还讨论了一些常见的并发问题,例如死锁、竞态条件等,并提供了相应的解决策略。
50 3
|
1月前
|
算法 调度 开发者
多线程编程核心:上下文切换深度解析
在多线程编程中,上下文切换是一个至关重要的概念,它直接影响到程序的性能和响应速度。本文将深入探讨上下文切换的含义、原因、影响以及如何优化,帮助你在工作和学习中更好地理解和应用多线程技术。
42 4
|
10天前
|
安全 Java API
【JavaEE】多线程编程引入——认识Thread类
Thread类,Thread中的run方法,在编程中怎么调度多线程