使用 C# 调用 CreateProcessW 函数的坑

本文虽然是写 .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());
}

}
}
}

将任意的应用程序作为 Windows 服务并解决 1053 错误

与 Linux 的 systemd 不同,并不是所有的应用程序都可以作为 Windows 服务。如果强行以服务形式启动某个不支持的应用程序,会得到“错误 1053: 服务没有及时响应启动服务和控制请求”。

注:如果你是一个开发人员,应当通过编程来满足成为 Windows 服务的条件,而不要使用本文的方法,可参考这篇文章这个例子

可以通过增加额外的代码来支持 Windows 服务。然而,如果我们无法获取软件的源代码,或者没有足够的代码能力,这条道路是行不通的。

微软在 Microsoft Windows Resource Kits 中提供了 instsrv.exe 和 srvany.exe 两个程序,可用来将任意程序作为 Windows 服务。顾名思义,instsrv.exe 的功能与 sc create 命令类似,而 srvany.exe 程序,则是一个响应 Windows 消息的一个“媒介”,它会间接启动我们需要的程序,并且向 Windows 报告服务的情况。

由于有更方便的 sc 命令,无需获得 instsrv.exe 程序,只需要获得 srvany.exe 即可。微软在 Microsoft Windows Resource Kits 中提供的 srvany.exe 文件是 32 位应用程序,如果在 64 位系统中运行 32 位程序,部分特殊文件夹和注册表会被重定向(如 System32 实际上是 SysWow64。但这并不意味着 32 位应用程序永远无法访问正确的资源,这可以通过特殊的 WinAPI 实现)。因此,需要 64 位的 srvany.exe,可从 GitHub 上获取一个开源的 srvany 实现

获取到合适的 srvany.exe 后,可将其放入与应用程序同一文件夹。然后继续剩余的步骤。

继续阅读

如何选择性屏蔽搜狐畅言的烦人提示

如你所见,伤心小栈采用了畅言评论框。使用它的原因呢,因为这是为数不多对https支持良好的国内评论框。

畅言评论框会在网站右下角弹出提示,比如有了新的回复,或者畅言的新鲜热评又出炉了。

前半部分很正经。像我这种有洁癖的人,受不了后半部分。该如何选择性地屏蔽畅言的提示呢?

继续阅读

[官方] OI Packages v2016.11

因为NOI官网更新了一系列编程工具的版本号,所以OI Packages也随之更新。

包含组件
编译器:MinGW32 4.8.1、FPC 2.6.2
调试器:GDB 7.7.1
集成开发环境:Orwell Dev-C++ 5.9.2、Lazarus 1.0.12、Free Pascal IDE 2.6.2
测评工具:Cena 0.8.2
代码编辑器:Notepad2

特点
1.分为考场配置、练习配置、最小配置等组件选择方案,也可以自由选择组件
2.组件全部为NOI公布的对应版本的编程工具,最大限度接近真实竞赛环境
3.剔除了所有集成开发环境自带的编译器,全部共享同一个版本的编译器
4.傻瓜式安装,自动完成环境变量配置、编译器配置和集成开发环境配置,可用于大批量部署(静默安装参数:/SILENT)
继续阅读