Call Object Method using ASM — Part 2
-
13-11-2019 - |
Question
This question is based on a previous, but that's just FYI.
I've managed to get it working, however, I've found something that's not clear to me, so if anyone can explain the following behaviour, it would be awesome.
I have the following class:
type
TMyObj = class
published
procedure testex(const s: string; const i: integer);
end;
procedure TMyObj.testex(const s: string; const i: integer);
begin
ShowMessage(s + IntToStr(i));
end;
and the following two procedures:
procedure CallObjMethWorking(AMethod: TMethod; const AStrValue: string; const AIntValue: Integer);
begin
asm
PUSH DWORD PTR AIntValue;
PUSH DWORD PTR AStrValue;
CALL AMethod.Code;
end;
end;
procedure CallObjMethNOTWorking(AInstance, ACode: Pointer; const AStrValue: string; const AIntValue: Integer);
begin
asm
MOV EAX, AInstance;
PUSH DWORD PTR AIntValue;
PUSH DWORD PTR AStrValue;
CALL ACode;
end;
end;
In order to test the working version, one needs to call the following:
procedure ...;
var
LObj: TMyObj;
LMethod: TMethod;
LStrVal: string;
LIntVal: Integer;
begin
LObj := TMyObj.Create;
try
LMethod.Data := Pointer( LObj );
LMethod.Code := LObj.MethodAddress('testex');
LStrVal := 'The year is:' + sLineBreak;
LIntVal := 2012;
CallObjMethWorking(LMethod, LStrVal, LIntVal);
finally
LObj.Free;
end; // tryf
end;
and in order to test the NOT working version:
procedure ...;
var
LObj: TMyObj;
LCode: Pointer;
LData: Pointer;
LStrVal: string;
LIntVal: Integer;
begin
LObj := TMyObj.Create;
try
LData := Pointer( LObj );
LCode := LObj.MethodAddress('testex');
LStrVal := 'The year is:' + sLineBreak;
LIntVal := 2012;
CallObjMethNOTWorking(LData, LCode, LStrVal, LIntVal);
finally
LObj.Free;
end; // tryf
end;
And finally the question: why isn't CallObjMethNOTWorking working, while CallObjMethWorking is? I'm guessing that there's something special in how the compiler treats TMethod... but since my assembly knowledge is limited, I can't understand it.
I would highly appreciate if someone could explain this to me, thank you!
Solution
Default calling convention in Delphi Win32 is "register". The first parameter is passed in EAX, the second in EDX and the third in ECX. The stack is only used if there are more than three parameters, or if value types larger than 4 bytes are passed, but that is not the case in your example.
Your first CallObjMethWorking procedure works because the compiler has already placed aStrValue in EDX and aIntValue in ECX when CallObjMethWorking was called. However, since you are not cleaning up your two push instructions, bad things are bound to happen when the procedure returns.
Your code should look like this. The stdcall directive is optional in this case, but it might be a good idea to use it for things like this to make sure your parameters aren't lost because you use the registers for other purposes before you get around to actually call the method:
procedure CallObjMeth(AInstance, ACode: Pointer; const AStrValue: string; const AIntValue: Integer); stdcall;
asm
MOV EAX, AInstance;
MOV EDX, DWORD PTR AStrValue;
MOV ECX DWORD PTR AIntValue;
CALL ACode;
end;
OTHER TIPS
Henrick Hellström is correct with his answer, and I notice that your question is tagged with Delphi 2010 and thus only concerns Win32. However, you might be interested to see what the situation would look like if you move forward to Win64 (Delphi >= XE2), so I added an example Win64 version to Henrick's code:
procedure CallObjMeth(AInstance, ACode: Pointer; const AStrValue: string; const AIntValue: Integer); stdcall;
asm
{$IFDEF CPU386}
MOV EAX, AInstance;
MOV EDX, DWORD PTR AStrValue;
MOV ECX, DWORD PTR AIntValue;
{$IFDEF MACOS}
//On MacOSX32 ESP = #######Ch here
SUB ESP, 0Ch
{$ENDIF}
CALL ACode;
{$IFDEF MACOS}
ADD ESP, 0Ch // restoring stack
{$ENDIF}
{$ENDIF}
{$IFDEF CPUX64}{$IFDEF WIN64} // <- see comments
.NOFRAME //Disable stack frame generation
//MOV RCX, AInstance {RCX} //<- not necessary because AInstance already is in RCX
MOV R10, ACode {RDX}
MOV RDX, AStrValue {R8}
MOV R8D, AIntValue {R9D}
SUB RSP, 28h //Set up stack shadow space and align stack: 4*8 bytes for 4 params + 8 bytes bytes for alignment
{$IFNDEF DO_NOT_TEST_STACK_ALIGNMENT}
MOVDQA XMM5, [RSP] //Ensure that RSP is aligned to DQWORD boundary -> exception otherwise
{$ENDIF}
CALL R10 //ACode
ADD RSP, 28h //Restore stack
{$ENDIF}{$ENDIF}
end;
There are several explanatory notes to make:
1) ASM
statement: In Delphi XE2 x64 there is no mixing of pascal and asm code, so the only way to write assembly code is in a routine that is comprised of a single asm..end
block, no begin..end
. Note that the begin..end
around your 32-bit asm code also does have an effect. Specifically, you are forcing the generation of a stack frame and let the compiler make local copies of the function parameters. (If you resort to using assembly in the first place, you would might not want the compiler to do that.)
2) Calling convention:
On Win64, there is only a single calling convention. Things like register
and stdcall
are effectively meaningless; it is all the same, Microsoft's Win64 calling convention. It is essentially this: parameters are passed in RCX
, RDX
, R8
and R9
registers (and/or XMM0-XMM4
, return values in RAX/XMM0
. Larger than 64-bit values are passed via reference.
Called functions may use: RAX, RCX, RDX, R8-R11, ST(0)-ST(7), XMM0-XMM5, YMM0-YMM5, YMM6H-YMM15H
, and must preserve RBX, RSI, RDI, RBP, R12-R15, XMM6-XMM15
. Where appropriate, called functions need to issue CLD
/EMMS
/VZEROUPPER
instructions to restore the CPU to the expected state.
3) Alignment and shadow space
Importantly, each function has its own shadow space on the stack, which is at least 4 QWORD param's worth of stack space, even if there are no params and irrespective of whether the called function actually touches it. Moreover, at the site of each function call (at each CALL
statement), RSP
is expected to be 16-byte aligned (same for ESP
on MacOSX32, btw.).
This often leads to things like: sub rsp, ##; call $$; add rsp, ##
constructs in which ## would be the sum of the (QWORD) parameters with which the function is to be called, plus an optional 8 bytes for alignment of RSP
. Note that the alignment of RSP
at the CALL
site results in RSP = ###8h
upon function entry (because CALL
puts the return address on the stack), so assuming nobody messes with RSP
before you do, you can expect it to be that.
In the example provided, the SSE2 MOVDQA
instruction is used to test the alignment of RSP
. (XMM5
is used as destination register because it can be freely modified yet cannot contain any function parameter data).
4) Assumptions
The code here assumes that the compiler does not insert code to change RSP
. There may be situations in which this may not be true, so beware of making this assumption.
5) Exception handling Exception handling in Win64 is a bit complicated and should properly be done by the compiler (the example code above does not do this). To allow the compiler to do so, ideally your code should use the new BASM directives/pseudo-instructions .PARAMS
, .PUSHNV
and .SAVENV
as outlined by Allen Bauer here. Given the right (wrong) situation, bad things could happen otherwise.