本文虽然是写 .NET 平台调用 CreateProcessW() 的坑,但是其中的几点在 C++ 中也会遇到。

首先,可能有人会问,.NET自己提供的 System.Diagnostics.Process 类已经很不错了,为什么放着好用的类不用,转而用Win32Api呢?其次,就算是使用Win32Api,ShellExecuteEx()或者WinExec()都比CreateProcess()简单得多啊。

答案很简单,CreateProcess()提供的功能是最多的,而我恰好要使用其中的某个功能——以挂起状态启动进程。即该进程不会立即进入就绪状态,而是先暂停,等程序慢悠悠地拿到句柄、对它做一些乱七八糟的“魔法”后,再让它正常启动——在CreateProcess()的dwCreationFlags中,设置好CREATE_SUSPENDED掩码即可。

和其他大多数Win32Api函数一样,CreateProcess()有CreateProcessA()和CreateProcessW()两个版本。以英语为母语的程序员不去关心A和W还有情可原,以东亚语言为母语的我们可不能。牢记一点:永远使用Unicode。也就是毫不犹豫地选择CreateProcessW()。

毕竟,A()和W()两个版本只有对字符串的处理不一样而已……吗?

大多数Win32Api函数确实是这样,但在CreateProcess()这里出了意外。

先贴上定义

BOOL CreateProcessW(
LPCWSTR lpApplicationName,
LPWSTR lpCommandLine,
LPSECURITY_ATTRIBUTES lpProcessAttributes,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
BOOL bInheritHandles,
DWORD dwCreationFlags,
LPVOID lpEnvironment,
LPCWSTR lpCurrentDirectory,
LPSTARTUPINFOW lpStartupInfo,
LPPROCESS_INFORMATION lpProcessInformation
);

A()和W()两个版本的参数完全一致(除了字符串类型)。然而,MSDN里面有这么一句话:

The Unicode version of this function, CreateProcessW, can modify the contents of this string. Therefore, this parameter cannot be a pointer to read-only memory (such as a const variable or a literal string). If this parameter is a constant string, the function may cause an access violation.

意思就是说,这个CreateProcessW()啊,有个小毛病儿。它在执行过程中会直接修改lpCommandLine这个字符数组的内容,然后等函数执行完毕的时候,再把lpCommandLine改回去。因此,如果lpCommandLine指向的是只读区域,那就完蛋了。比如说,CreateProcessW(nullptr,"C:\Windows\System32\mspaint.exe",...)

为什么CreateProcessA()没有这个问题呢?我们知道Windows NT内核是基于Unicode的,对Ansi的字符串最终会转换回Unicode版本进行执行。也就是说,CreateProcessA(),会把lpCommandLine进行一次Ansi->Unicode的转换,然后调用CreateProcessW()。这样一来,lpCommandLine经过了一次复制,就会避免上面的问题了。

这算是第一关,认真读文档就能避免。接下来转入 .NET。

在.NET里,要想调用Win32Api或者其他的DLL文件,需要使用麻烦的PInvoke。或许是微软自己也被这个恶心到了,于是有了一个软件 Microsoft P/Invoke Interop Assistant,可以一键复制Win32Api,或者自动把其他的C++函数调用转换为C#或VB.net语法。

软件生成的PInvoke代码如下。(经过人工美化)

[DllImport("kernel32.dll", EntryPoint="CreateProcessW")]
[return: MarshalAsAttribute(UnmanagedType.Bool)]
public static extern bool CreateProcessW(
[In] [MarshalAsAttribute(UnmanagedType.LPWStr)] string lpApplicationName,
IntPtr lpCommandLine,
[In] IntPtr lpProcessAttributes,
[In] IntPtr lpThreadAttributes,
[MarshalAsAttribute(UnmanagedType.Bool)] bool bInheritHandles,
uint dwCreationFlags,
[In] IntPtr lpEnvironment,
[In] [MarshalAsAttribute(UnmanagedType.LPWStr)] string lpCurrentDirectory,
[In] ref STARTUPINFOW lpStartupInfo,
[Out] out PROCESS_INFORMATION lpProcessInformation) ;

可以看到,软件注意到了 lpCommandLine 的与众不同。然而你给个 IntPtr 是什么意思?让我自己管理这个字符串的内存吗?
既然要求字符串的内存区域可读写,普通的string是不合适的,但是StringBuilder再合适不过了。直接把IntPtr lpCommandLine,改为StringBuilder lpCommandLine,即可满足函数的要求。
STARTUPINFOW 和 PROCESS_INFORMATION 是两个 struct,为了精简内容,暂时隐去这部分,后面再提。

我一开始在lpEnvironment上犯了个错误,也把IntPtr替换为了StringBuilder。然而,lpEnvironment根本不是一个字符串,而是一个EnvironmentBlock,需要用CreateEnvironmentBlock()创建。一般情况下,给他nullptr也就是C#的IntPtr.Zero就可以了,程序会自动继承系统的环境设置。如果给他一个空字符串,new StringBuilder(),会导致所有的环境变量被清空——不光是%path%!所有你能想到的,%windir%,%temp%,%appdata%……都没有了。这样一来,只有极少程序,比如notepad.exe可以正常启动,但是mspaint.exe就不行了。

好,按C#代码执行,
var si = new STARTUPINFOW();
si.cb = (uint) Marshal.SizeOf(si);
var pi = new PROCESS_INFORMATION();
if (!NativeMethods.CreateProcessW(@"C:\Windows\System32\mspaint.exe", new StringBuilder(), IntPtr.Zero, IntPtr.Zero, false, 0, IntPtr.Zero, "", ref si, out pi))
throw new Exception("CreateProcess failure, error "+NativeMethods.GetLastError());

嗯,出错,代码123。翻译成人话就是“文件名、目录名或卷标语法不正确”。当然,mspaint.exe确实在它该在的地方,这点不用怀疑。
给答案:lpCurrentDirectory这个字符串,设置为nullptr和””是不一样的。前者取当前进程的工作目录为新进程的工作目录,后者指定””为工作目录(这是不允许的)。

好,那就把工作目录改为null或者mspaint.exe所在目录。

if (!NativeMethods.CreateProcessW(@"C:\Windows\System32\mspaint.exe", new StringBuilder(), IntPtr.Zero, IntPtr.Zero, false, 0, IntPtr.Zero, null, ref si, out pi))
throw new Exception("CreateProcess failure, error "+NativeMethods.GetLastError());

继续出错,代码2。也就是“系统找不到指定的文件”。

这次的答案更加扑朔迷离了,我直接揭晓:把[DllImport("kernel32.dll", EntryPoint="CreateProcessW")]改成[DllImport("kernel32.dll", EntryPoint = "CreateProcessW",CharSet =CharSet.Unicode)]

相信不少朋友看到这个改动会恍然大悟。C语言字符串以\0结尾,但Unicode(更确切地说是UTF-16 LE)以两个字节为一个字符(不讨论多于2个字符的情况,比如最少占4字节的emoji表情),而这两个字节里可以存在\0。如果按ANSI或者UTF-8处理字符串(逐字节处理),只要字符串里一个字节为0,这个字符串即终止。而UTF-16 LE的字母C怎么存储呢?“0x43 0x00”。
MSDN里清晰地说明了,The default enumeration member for C# and Visual Basic is CharSet.Ansi。也就是如果你不设置Charset,就按最古老的Ansi算……哪怕你调用的是W版本。

改完后,画图程序终于出现了。

假设要让画图打开一个文件怎么办呢?按照以往编程的经验,应该有两个字符串,第一个字符串是画图程序的全路径,第二个字符串给定第1个参数,也就是要打开的文件路径,最好加上双引号以便兼容空格。

NativeMethods.CreateProcessW(@"C:\Windows\System32\mspaint.exe", new StringBuilder("\"D:\\test.jpg\""), IntPtr.Zero, IntPtr.Zero, false, 0, IntPtr.Zero, null, ref si, out pi);

画图程序出现,什么文件也没打开……

原来 lpCommandLine 要给出所有的参数。虽然第1个参数就是要打开的文件,也就是%1,从DOS时代过来的朋友应该是很熟悉了。可是……咱忘了第0个参数。

第0个参数是程序本身的路径,也就是画图程序的全路径,最好加上引号。

……

这么说CreateProcessW的第一个参数还有什么意义?一个lpCommandLine就够了,前面的lpApplicationName是专门来捣乱的吗?

确实是这样,大多数情况下,lpApplicationName置为nullptr,只使用lpCommandLine……

NativeMethods.CreateProcessW(null, new StringBuilder("\"C:\\Windows\\System32\\mspaint.exe\" \"D:\\test.jpg\""), IntPtr.Zero, IntPtr.Zero, false, 0, IntPtr.Zero, null, ref si, out pi);

OK,这次画图程序成功打开了文件。

说回到一开始,var si = new STARTUPINFOW();
si.cb = (uint) Marshal.SizeOf(si);
var pi = new PROCESS_INFORMATION();

之所以要给si.cb赋值,这是这个函数的规定,结构体的第一个变量cb的值就是该结构体的字节数。不难理解。

注意这里面的 STARTUPINFOW 和 PROCESS_INFORMATION 都是strcut而不是class。如果是后者的话,CreateProcessW的最后两个参数不用加ref和out,对应的函数声明也要去掉这两个关键字。.NET的Marshal会自动帮我们处理好大多数情况的内存复制,因此我们无需关心内存管理。

最后把完整的C#代码贴一下。如果你的CreateProcess还没调试成功,不妨直接复制我的。

另外,更建议参考 Chromium 的 Win32Api 的调用,写的比底下这份好多了。

using System;
using System.Runtime.InteropServices;
using System.Text;

namespace ConsoleApp1
{
class Program
{

[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public uint nLength;
public IntPtr lpSecurityDescriptor;
[MarshalAs(UnmanagedType.Bool)]
public bool bInheritHandle;
}

[StructLayout(LayoutKind.Sequential)]
public struct STARTUPINFOW
{
public uint cb;
[MarshalAs(UnmanagedType.LPWStr)]
public string lpReserved;
[MarshalAs(UnmanagedType.LPWStr)]
public string lpDesktop;
[MarshalAs(UnmanagedType.LPWStr)]
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}

[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}

public partial class NativeMethods
{
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool CreateProcessW(
[In] [MarshalAs(UnmanagedType.LPWStr)] string lpApplicationName,
[In, Out] StringBuilder lpCommandLine,
[In] IntPtr lpProcessAttributes,
[In] IntPtr lpThreadAttributes,
[MarshalAs(UnmanagedType.Bool)] bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
[In][MarshalAs(UnmanagedType.LPWStr)] string lpCurrentDirectory,
[In] ref STARTUPINFOW lpStartupInfo,
[Out] out PROCESS_INFORMATION lpProcessInformation);

[DllImport("kernel32.dll", EntryPoint = "GetLastError")]
public static extern uint GetLastError();
}

static void Main(string[] args)
{
var si = new STARTUPINFOW();
si.cb = (uint)Marshal.SizeOf(si);
var pi = new PROCESS_INFORMATION();
if (!NativeMethods.CreateProcessW(
null, new StringBuilder("\"C:\\Windows\\System32\\mspaint.exe\" \"D:\\test.jpg\""),
IntPtr.Zero, IntPtr.Zero, false, 0, IntPtr.Zero, null, ref si, out pi))
{
throw new Exception("CreateProcess failure, error " + NativeMethods.GetLastError());
}

}
}
}