![feca485423febde5615ad5216b97cf8c.png](https://i-blog.csdnimg.cn/blog_migrate/13a9760ac7578a6f990ca01ed57404ac.jpeg)
说到stack(栈),大家很可能就会想起stack overflow(栈溢出),著名的程序问答网站http://stackoverflow.com 就是以此命名的。因为栈通常是从高地址向低地址增长的,因此"栈溢出"分为两种:超出低地址范围的overrun(上溢)和超出高地址范围的underrun(下溢),"上溢"主要是由过深的函数调用引起(比如递归调用):
![d75d4e50f8297f873db9a3670c709ed6.png](https://i-blog.csdnimg.cn/blog_migrate/de6712be7265c4828b50940e49a7f847.jpeg)
而"下溢"则会出现在数组/字符串越界的时候(数组的内存分布是从低地址到高地址的)。
![835923e3b0fe36a0d89c1c70339b28c1.png](https://i-blog.csdnimg.cn/blog_migrate/25935242bebf61da8579cfa3dc0208d1.jpeg)
因为"栈溢出"造成的数据破坏很可能不会在被破坏的那一瞬间立刻显现,而是像幽灵一样潜伏着,直到之后的某个时刻,被破坏的数据再次被访问到。为了将这些幽灵扼杀在摇篮里,我们需要一些针对"栈溢出"的检测机制。
RTOS的栈溢出检测
对于那些不使用虚拟内存机制的RTOS,通常采用的做法是在stack创建之初就填充满固定的字符(比如0x5a5a5a5a),如果发生了"上溢",那么stack末端(最低地址处)的填充字符则有可能会被更改。
这样操作系统就可以在发生线程切换的时候,通过检测线程栈的末端字符(比如最后16个字节)是否被更改来判断是否有"上溢"发生,当然这会增加一些线程切换的开销。之所以说是“有可能”,是因为末端的那段字节可能正好被跳过,所以这种检测方法并不是100%有效的。
![62b39838faada3140a7b6f64345bca06.png](https://i-blog.csdnimg.cn/blog_migrate/0da5a74600637c0cdfee9edc5e878c25.jpeg)
这种方法除了可以用来检测"栈溢出",还可以用来查看栈的watermark(水位线)。当上游的进水量较大的时候,河流的水面就会升高,而后即便水面下降,之前水位到达的最高点处依然是湿的。
同样的道理,在线程运行过程中,栈空间的使用率有起有落,但没有被覆盖过的"0x5a5a5a5a"一定是栈未曾达到过的区域,由此我们可以计算出栈的最大使用率。如果这个最大使用率已经逼近栈的极限(最低地址),那么我们就应该适当增加该线程的栈空间大小,避免在更极端的情况下出现"栈溢出"。
至于"下溢",则可以在将函数的返回地址压栈的时候,加上一个随机产生的整数,如果出现了数组越界,那么这个整数将被修改,这样在函数返回的时候,就可以通过检测这个整数是否被修改,来判断是否有"下溢"发生。
这个随机的整数被称为"canary",它的原意是金丝雀,这种鸟对危险气体的敏感度超过人类,所以过去煤矿工人往往会带着金丝雀下井,如果金丝雀死了,矿工便知道井下有危险气体,需要撤离。
![f4fc6a906e1401d2c78946859a64699c.png](https://i-blog.csdnimg.cn/blog_migrate/894c33a4c7bf470938c5b6a3de0f3fa1.jpeg)
那怎么加上这个canary呢,只需要在gcc编译的时候,加入"-fstack-protector"选项即可。一个函数对应一个stack frame,每个stack frame都需要一个canary,这会消耗掉一部分的栈空间。此外,由于每次函数返回时都需要检测canary,代码的整执行时间也势必会增加。
![d22efbd2c0cccb8086e7dc809fb736c0.png](https://i-blog.csdnimg.cn/blog_migrate/a1537ecce5d4b742f6795d22501c5997.jpeg)
"上溢"或者"下溢"发生的时候,栈顶指针(SP - Stack Pointer)一定会超出栈的范围,所以也可以在发生线程切换的时候,检测SP指向的地址是否超过了栈的内存限定。在线程切换之前,因为需要保存线程的上下文,此时栈的使用率相对是比较高的,但这并不一定是发生overflow的时刻,所以这只能作为一种辅助的检测手段。
![8f2f6bc41e87638b90a2e9372c8ea54d.png](https://i-blog.csdnimg.cn/blog_migrate/620bf5a1f840354e65cef05fa1502b9e.jpeg)
Linux的栈溢出检测
对于使用页表机制的Linux,借助于MMU提供的各项内存管理功能,对内存越界的检测变得容易了很多,比如vmalloc区域中广泛采用的guard page。这种guard page存在于虚拟地址空间,并不会占用实际的物理内存,但是如果内核线程的栈也想用guard page来检测"栈溢出",情况就另当别论了。
在4.14版本之前,Linux的内核栈所使用的内存位于线性映射的区域,这样的内存可以享受线性映射提供的诸多便利,包括不需要建立页表的映射,分配速度更快,可以更好的利用cache等(参考这篇文章),但有得必有失,它同时也就无法获得虚拟内存带来的若干好处了。
使用线性映射,意味着占据虚拟地址空间的同时也会占用物理内存,本来一个内核栈的尺寸就比较小(8KiB或者16KiB),加上那么多guard pages对物理内存的浪费是不容小视的。
在Linux的4.14版本,内核的配置多了一个"CONFIG_VMAP_STACK"选项。该选项是默认使能的,其带来的变化在于,内核的栈将使用vmalloc区域来获取内存,这样内核栈可以利用vmalloc现成的guard page机制来检测"栈溢出",但同时,其对应的物理内存也将不再保证是连续的。
在32位系统中,vmalloc区域通常小于128MiB,资源比较紧张,所以这个配置更适合用在64位系统中。不过,4.14是2017年11月发布的,这时大多数运行Linux的处理器应该都已经进入64位时代了。
这个变化对我们的代码会产生什么影响呢?因为栈空间不再是物理连续的,所以你不能再把它(比如函数里定义的一个数组)作为DMA传输的目标地址。如果要临时开辟一段内存空间给DMA用,请老老实实的用kmalloc()函数来分配。
来看下Linux是如何制造"栈溢出"来测试这个新的"VMAP_STACK"机制的。对于"上溢",即访问了比栈的最低地址更小的内存部分,采用的guard page叫"leading":
/* Test that VMAP_STACK is actually allocating with a leading guard page */
对于"下溢",即访问了比栈的最高地址更大的内存部分,采用的guard page叫"trailing":
/* Test that VMAP_STACK is actually allocating with a trailing guard page */
没有"栈溢出"也并不代表栈的使用就完全没有问题,在Linux的4.9版本之前,每个线程除了描述其各项相关信息的task_struct,还拥有一个thread_info结构体。这个"thread_info"位于线程栈的最低地址处,这样通过thread_info可以方便地获取到线程的task_struct指针。
如果栈的内存使用达到了thread_info的区域,虽然此时"栈溢出"还没有发生,但thread_info的数据结构会受到破坏,可能造成这个线程之后无法正常运行。
![96e9d3106bc11921268243680946d3ea.png](https://i-blog.csdnimg.cn/blog_migrate/47770e658ad7c7499269e8e0d9520662.jpeg)
从Linux 4.1开始,thread_info就在逐渐被简化,直到4.9版本彻底不再通过thread_info获取task_struct指针,而thread_info本身也被移入了task_struct结构体中,所以这个问题也就不复存在了。
参考:
"Strong" stack protection for GCC
Virtually mapped kernel stacks
原创文章,转载请注明出处。