Generating a Minimal Executable with Visual Studio and MASM
Learn how to write an assembly program with MASM and link it to get a minimal executable. Just install Visual Studio and a Windows SDK, then follow the steps below.
First, we need to set up the prototypes in the assembly code as imports if we are using the Microsoft MASM assembler, rather than an open-source equivalent.
Also, define any constants. A simple “Hello World” program via a message box is a good example.
.586
.model flat, stdcall
option casemap :none
MessageBoxA PROTO STDCALL :DWORD,:DWORD,:DWORD,:DWORD
ExitProcess PROTO STDCALL :DWORD
MB_OK EQU 0
NULL EQU 0
.data
message db 'Hello, World!', 0
.code
main PROC
push MB_OK
push offset message
push offset message
push NULL
call MessageBoxA
push 0
call ExitProcess
main ENDP
end main
After the assembly file is written, we need to assemble the assembly before linking.
ml /c /coff /Fo"main.obj" "main.asm"
Next, we just need to link – but with a number of options (command switches).
link /SUBSYSTEM:WINDOWS /RELEASE /ALIGN:16 /OPT:REF /OPT:ICF /INCREMENTAL:NO /FUNCTIONPADMIN /MERGE:.rdata=.text /DEBUG:NONE /EMITPOGOPHASEINFO main.obj Kernel32.Lib User32.Lib
All of this will result in a fairly optimized and minimal executable, created by the Microsoft Visual Studio tools and Windows SDK.
The switch to remove debug information, /DEBUG:NONE /EMITPOGOPHASEINFO
, is seemingly not documented.
To minimize the size of sections, for instance, .text
and .data
, the /ALIGN
switch can be used with values set as powers of two.
All of this will result in a fairly optimized and minimal executable, created by the Microsoft Visual Studio tools and Windows SDK.
It also seems possible to generate smaller binaries based on the VS version used, the same file generated with VS 2017 is 850 bytes versus 1.1k bytes in VS 2022. The reasons for which, would need exploring further and any compatibility implications across windows versions.
You can also generate a pure C based executable too.
.386
.MODEL FLAT, STDCALL
;includelib ucrt.lib
;includelib legacy_stdio_definitions.lib
EXTERN printf: PROC
.data
fmtStr db 'Foo bar baz', 0
.code
main PROC
push offset fmtStr ; Push the address of the format string onto the stack
call printf ; Call printf
add esp, 4 ; Clean up the stack (4 bytes for 32-bit address)
ret
main ENDP
END main
You can uncomment the library includes or pass them at the command line to the linker.
ml /c /coff /Fo"test1.obj" "test1.asm"
link /SUBSYSTEM:CONSOLE /RELEASE test1.obj ucrt.lib legacy_stdio_definitions.lib
If we actually disassemble the final executable in IDA. We will observe multiple subroutine calls leading to a call to __stdio_common_vfprintf
; whereas we would naturally expect a call to printf
.
This is due to the way modern C runtimes are implemented and the printf
function being more complex than it appears. When we call it, the function must handle various format specifiers, width, precision, flags, and different types of arguments (integers, floats, strings, etc.) – which often requires a layered design for efficient and reusable code.