
.Net Windbg 与汇编基础(学习笔记)
为什么要学习汇编?
有时候再Debug下可以运行的逻辑,但在Release下却无法实现。
举例:主线程创建一个工作线程,在500ms后准备终止工作线程,在Release模式下无法终止。。。(代码如下)
internal class Program
{
static void Main(string[] args)
{
var worker = new Worker();
Console.WriteLine("Main thread: Start the work thread...");
var workerTask = Task.Run(worker.DoWork);
// 等待 500 毫秒以确保工作线程已在执行
Thread.Sleep(500);
Console.WriteLine("Main thread: request to terminate the work thread...");
worker.RequestStop();
workerTask.Wait();
Console.WriteLine("Main thread: The work thread is terminated");
Console.ReadLine();
}
}
public class Worker
{
private bool _shouldStop;
public void DoWork()
{
bool work = false;
while (!_shouldStop)
{
work = !work; // do sth.
}
Console.WriteLine("Work threads: being terminated...");
}
public void RequestStop()
{
_shouldStop = true;
}
}
接下来我们通过Debug运行,通过改变_shouldStop变量的值,发现该线程正常的在500ms后退出。
然后我们通过Release运行,并进行发布却没有达到预期的效果。
随后我们通过windbg附加进程的方式进行调试。
首先我们来看一下所有线程栈。
~*e !clrstack
我们可以看到指向了Program类中的DoWork方法第41行,我们可以点一下,也可以执行一下下面的命令看执行哪里。
!U /d 00007ff933d7bdef
接着我们可以下个断点。
bp 00007ff9`33d7bdf4
g
然后我们打开汇编窗口。
通过执行t
命令不断的单步执行,我们发现主要执行的是如下几句汇编代码。
test
汇编命令表示判断寄存器中ecx
与ecx
的值。je
如果判断的值相等就跳转到00007FF933D7BDEA
这个位置。
我们先来看看ecx
的值
r ecx
0
永远等于0
,所以这个问题的关键在于它把_shouldStop
的值放到了寄存器中,无论你怎么改变值它寄存器中永远不会改变,所以解决方法就是让它不从寄存器从内存中去比较值。
我们可以看看当前内存中的_shouldStop
值。
!name2ee Exmaple_2_1_1!Exmaple_2_1_1.Worker
# 这样找也可以 加类名
!dumpheap -type Worker
# 然后我们找到第一个地址,可以看到_shouldStop值为1
!DumpObj /d 00000251407eb1d0
出现了这种情况,我们应该给_shouldStop
添加上volatile
关键字,表示该值是一个异变的结构。
private volatile bool _shouldStop;
然后我们再次运行程序,发现就可以了。
我们再通过windbg的方式来看看。
!name2ee Exmaple_2_1_1!Exmaple_2_1_1.Worker.DoWork
!U /d 00007ff933d6bde0
在第39行中我们发现与原来的汇编代码有所出入,这里是直接通过rcx+8与0进行比较(rcx表示类的地址,+8表示偏移8位)。
直接从内存中进行比较就没问题了。
内存单元和CPU三大总线
理解内存单元
Bit:计算机中最小的信息单元 (8bit=1Byte), Bitmap 算法就得益于它的威力。
Byte:计算机中最小的数据存储单元,简而言之,一个地址占用一个1byte。
我们通过vs来查看数据的格式大小。
static void Main(string[] args)
{
Console.WriteLine("hello world!");
Console.ReadLine();
}
接下来,我们也可以通过windbg来进行查看。
对应的依次是:db,dw,dd, dq。
CPU三大总线
地址总线: 现在的 intel i7 的CPU上,一般是 48 根地址总线,每根线可以表示 0/1 两种状态(高电平,低电平),
所以它最多能表示 248 个地址,即地址范围是: 0 ~ 0000ffff`ffffffff。
一个地址能存放一个byte,所以最大寻址空间为:248 * 1byte = 256T
我们可以通过!address -summary
命令查看地址空间,用户空间占用126T。
这里用户态的最大寻址空间为128T
可以看到我这里Free+MappendFile的总和就是用户态最大的存储地址空间,另外还有128T
在内核态中。
如果你想看更详细的请执行!address
命令
数据总线: 现在的 intel i7 的CPU上,一般是 64 根数据总线,它决定了一次性可以从内存中读取 64bit 的数据,也就是 8byte。
比如说:一个 long 类型,在 32 位操作系统上需要走两次内存,在 64bit 上只需要一次。
控制总线: 它决定了对计算机外部器件的控制能力,比如说对内存可以发起 “读”或 “写” 命令。
综上:CPU读写内存,需要经过 地址总线,数据总线,控制总线 的多次往返,那么如何规避这些不必要的开销是我们思考的问题! 比如合理利用 CPU 内部的“CPU 缓存 & CPU 寄存器”
常见的寄存器
1.寄存器是寄宿于 CPU 中的信息存储部件,通过内部总线实现了高效计算,比读内存速度要快几个数量级。
2.在 高级调试 中,我们需要熟练掌握这 10 个寄存器 (32bit) ,大体上分为 4 类。
数据寄存器
寄存器名称 | 描述 |
---|---|
EAX | 累加器 |
EBX | 基数寄存器 |
ECX | 计数寄存器 |
EDX | 数据寄存器 |
JIT 在将 IL 代码转成 汇编代码的时候,一般会遵守一些约定成俗的规定,比如:
在数据的 + ,- ,*,/
方面,优先会使用 eax 寄存器,在方法的返回值上面,在方法同样优先使用 eax 。
(举例代码如下)
static void Main(string[] args)
{
/* +,-,*,/ */
var a = 10;
var b = a + 10;
var c = a - 20;
var d = ++a;
//获取方法返回值
var age = GetAge();
}
static int GetAge()
{
int age = 10;
return age;
}
为[ebp-3Ch]
赋值操作
mov dword ptr [ebp-3Ch],0Ah
先将eax寄存器附上[ebp-3Ch]
(a)的值,然后做一个add
相加0Ah
(这里是16进制相当于10进制的10),然后再赋值给[ebp-40h]
(b)。
mov eax,dword ptr [ebp-3Ch]
add eax,0Ah
mov dword ptr [ebp-40h],eax
与上不同的是它这里加的负数。
mov eax,dword ptr [ebp-3Ch]
add eax,0FFFFFFECh
mov dword ptr [ebp-44h],eax
然后我们来看方法返回值这里,返回是通过EAX寄存器来进行赋值的,我们可以通过按F10来进行调试。
call CLRStub[MethodDescPrestub]@a859fb0804a0ac18 (04A0AC18h)
mov dword ptr [ebp-54h],eax
mov eax,dword ptr [ebp-54h]
mov dword ptr [ebp-4Ch],eax
可以清晰的看到EAX发生了改变。
变址寄存器
寄存器名称 | 描述 |
---|---|
ESI | 源变址寄存器 |
EDI | 目的变址寄存器 |
常用于做字符串的赋值,比如在 C 语言的 main 序幕代码中,就有一段初始化栈空间的操作,代码的意思就是从 edi 开始,依次将 eax 中的 0CCCCCCCCh 赋值 ecx=0x17(转换成10进制就是23次) 次,可以看到这段区间内都是 cc 符。(代码如下)
#include <iostream>
int main()
{
int nums[20] = { 10,11,12,13 };
}
栈指针寄存器
寄存器名称 | 描述 |
---|---|
EBP | 基址指针寄存器 |
ESP | 堆栈指针寄存器 |
每一个方法都有一个属于自己的方法栈帧,这个栈帧的范围就是用 EBP 和 ESP 标识的。
我们可以通过刚刚的例子通过汇编代码用ESP算出EBP的大小。
mov ebp,esp
sub esp,11Ch
push ebx
push esi
push edi
当前esp为00CFF85C
,然后加上11C
,再加上的三个入栈的ebx
、esi
、edi
每个占4字节,最后得出EBP的基栈地址CFF984
。
控制寄存器
寄存器名称 | 描述 |
---|---|
EIP | 指令指针寄存器 |
PSW | 状态标志寄存器 |
EIP: 用来保存程序下一步需要执行的指令地址,跳跃的长度就是机器码的byte数。
PSW :用来保存运算(CMP,TEST)过程中出现的比如 ZF(零标志位), OF (溢出标志)标志位等。
常见语句的汇编代码
赋值语句
在这里a,b,c赋值时它们的值分别为0,0,10
,我们来看汇编就一目了然,先将b放入eax中再给a赋值eax,然后再把c放入eax中再给b赋值eax,最后给c赋值0AH
(十进制为10)。
internal class Program
{
private static int a = b;
private static int b = c;
private static int c = 10;
static void Main(string[] args)
{
Console.WriteLine($"a={a},b={b},c={c}");
var txt = Convert.ToInt32(Console.ReadLine());
if (txt == 2)
{
Console.WriteLine("txt==2");
}
else
{
Console.WriteLine("txt!=2");
}
}
}
mov:用来将源操作数复制到目的操作数当中,是一个数据传送指令。
条件跳转语句
命令名称 | 格式 | 描述 |
---|---|---|
CMP | CMP destination,source | 目的操作数减去源操作数的隐含减法操作,不修改任何操作数。 |
PSW | JE address (Jump Equals) | 即 ZF=1 时跳转。 |
JMP | 无条件地址跳转指令。 | |
CALL | 函数调用指令。 |
案例样本分析
在一个dump中发生栈溢出的情况,发现在某一个 IsMatched 方法中,汇编代码高达9w行,rsp +xxxx 高达 8w 行,导致默认的 1M 栈空间不够而溢出!
经过分析发现他用了一个超大的struct,而且还有嵌套 struct, 当一个方法的“参数”和“返回值”都是 struct 时,会在父方法和子方法的栈上分配大量的 栈空间。
观察下面代码看会产生多少struct。
internal class Program
{
static void Main(string[] args)
{
Person person1 = new Person() { A = int.MaxValue, B = int.MaxValue, C = int.MaxValue, D = int.MaxValue };
Person person2 = person1;
Console.WriteLine(person2.A);
}
static Person Test(Person person)
{
person.A = int.MaxValue;
person.B = int.MaxValue;
person.C = int.MaxValue;
person.D = int.MaxValue;
return person;
}
}
public struct Person
{
public int A;
public int B;
public int C;
public int D;
}
通过EBP发现数据一共有六对,每4个7fffffff
为一对,主要的结果如下:
欢迎加群讨论技术,1群:677373950(满了,可以加,但通过不了),2群:656732739

