在 Windows 上解析 UTF-8 命令行参数的这个奇怪问题的原因是啥?

Posted

技术标签:

【中文标题】在 Windows 上解析 UTF-8 命令行参数的这个奇怪问题的原因是啥?【英文标题】:What is the reason for this bizarre issue parsing a UTF-8 command line argument on Windows?在 Windows 上解析 UTF-8 命令行参数的这个奇怪问题的原因是什么? 【发布时间】:2020-12-31 02:43:24 【问题描述】:

我正在尝试传入一个使用 UNICODE 字符的字符串:"right single quotation mark" Decimal: 8217 Hex: \x2019

Perl 没有正确接收字符。让我告诉你细节:

Perl 脚本如下(我们称之为test.pl):

use warnings;
use strict;
use v5.32;
use utf8; # Some UTF-8 chars are present in the code's comments

# Get the first argument
my $arg=shift @ARGV or die 'This script requires one argument';

# Get some env vars with sensible defaults if absent
my $lc_all=$ENVLC_ALL // 'unset';
my $lc_ctype=$ENVLC_CTYPE // 'unset';
my $lang=$ENVLANG // 'unset';

# Determine the current Windows code page
my ($active_codepage)=`chcp 2>NUL`=~/: (\d+)/;

# Our environment
say "ENV: LC_ALL=$lc_all LC_CTYPE=$lc_ctype LANG=$lang";
say "Active code page: $active_codepage"; # Note: 65001 is UTF-8

# Saying the wrong thing, expected: 0’s    #### Note: Between the '0' and the 's'
#   is a "right single quotation mark" and should be in utf-8 => 
#   Decimal: 8217 Hex: \x2019
# For some strange reason the bytes "\x2019" are coming in as "\x92" 
#   which is the single-byte CP1252 representation of the character "right 
#   single quotation mark"
# The whole workflow is UTF-8, so I don't know where there is a CP1252 
#   translation of the input argument (outside of Perl that is)

# Display the value of the argument and its length
say "Argument: $arg length: ",length($arg);

# Display the bytes that make up the argument's string
print("Argument hex bytes:");
for my $chr_idx (0 .. length($arg)-1)

  print sprintf(' %02x',ord(substr($arg,$chr_idx,1)));

say ''; # Newline

我按如下方式运行 Perl 脚本:

V:\videos>c:\perl\5.32.0\bin\perl test.pl 0’s

输出:

ENV: LC_ALL=en-US.UTF-8 LC_CTYPE=unset LANG=en_US.UTF-8
Argument: 0s length: 3
Argument hex bytes: 30 92 73

好的,也许我们还需要指定 UTF-8 everything(stdin/out/err 和命令行参数)?

V:\videos>c:\perl\5.32.0\bin\perl -CSDA test.pl 0’s

输出:

ENV: LC_ALL=en-US.UTF-8 LC_CTYPE=unset LANG=en_US.UTF-8
Active code page: 65001
Argument: 0s length: 3
Argument hex bytes: 30 92 73

好的,让我们尝试完全删除所有LC*/LANG env vars,结果是:

@SET LC_ALL=
@SET LANG=

@REM Proof that everything has been cleared
@REM Note: The caret before the vertical bar escapes it,
@REM       because I have grep set up to run through a
@REM       batch file and need to forward args
@set | grep -iP "LC^|LANG" || echo %errorlevel%

输出:

1

让我们再次尝试使用 UTF-8 执行脚本:

V:\videos>c:\perl\5.32.0\bin\perl -CSDA 0’s

输出(没有变化,除了 LC*/LANG 环境变量已被清除):

ENV: LC_ALL=unset LC_CTYPE=unset LANG=unset
Active code page: 65001
Argument: 0s length: 3
Argument hex bytes: 30 92 73

此时,我决定跳出 Perl,看看 Windows 10 本身对我的命令行参数做了什么。我有一个不久前用 C# 编写的小实用程序,它可以帮助解决命令行参数问题并用它来测试。输出应该是不言自明的:

V:\videos>ShowArgs 0’s

Filename: |ShowArgs.exe|
Pathname: |c:\bin\ShowArgs.exe|
Work dir:  |V:\videos|

Command line: ShowArgs  0’s

Raw command line characters:

000: |ShowArgs  |: S (083:53) h (104:68) o (111:6F) w (119:77) A (065:41) r (114:72) g (103:67) s (115:73)   (032:20)   (032:20)
010: |0’s       |: 0 (048:30) ’ (8217:2019) s (115:73)

Command line args:

00: |0’s|

这显示了几件事:

    传入的参数不需要引用(没想到会这样) Windows 以 UTF-8 格式将参数正确传递给应用程序

我这辈子都想不通为什么 Perl 现在没有接收到 UTF-8 的参数。

当然,作为一个绝对的hack,如果我在我的 Perl 脚本底部添加以下内容,问题就会得到解决。但我想了解为什么 Perl 没有收到 UTF-8 的参数:

# ... Appended to original script shown at top ...
use Encode qw(encode decode);

sub recode 
 
  return encode('UTF-8', decode( 'cp1252', $_[0] ));


say "\n@['='x60]\n"; # Output separator
say "Original arg: $arg";
say "After recoding CP1252 -> UTF-8: $\recode($arg)";

脚本执行:

V:\videos>c:\perl\5.32.0\bin\perl test.pl 0’s

新输出:

ENV: LC_ALL=en_US.UTF-8 LC_CTYPE=unset LANG=unset
Active code page: 65001
Argument: 0s length: 3
Argument hex bytes: 0030 0092 0073

============================================================

Original arg: 0s
After recoding CP1252 -> UTF-8: 0’s

更新

我构建了一个简单的 C++ 测试应用程序来更好地了解正在发生的事情。

这里是源代码:

#include <cstdint>
#include <cstring>
#include <iostream>
#include <iomanip>

int main(int argc, const char *argv[])

  if (argc!=2)
  
    std::cerr << "A single command line argument is required\n";
    return 1;
  

  const char *arg=argv[1];
  std::size_t arg_len=strlen(arg);

  // Display argument as a string
  std::cout << "Argument: " << arg << " length: " << arg_len << '\n';

  // Display argument bytes
  // Fill with leading zeroes
  auto orig_fill_char=std::cout.fill('0');

  std::cout << "Bytes of argument, in hex:";
  std::cout << std::hex;
  for (std::size_t arg_idx=0; arg_idx<arg_len; ++arg_idx)
  
    // Note: The cast to uint16_t is necessary because uint8_t is formatted 
    //       "specially" (i.e., still as a char and not as an int)
    //       The cast through uint8_t is necessary due to sign extension of
    //       the original char if going directly to uint16_t and the (signed) char
    //       value is negative.
    //       I could have also masked off the high byte after the cast, with
    //       insertion code like (Note: Parens required due to precedence):
    //         << (static_cast<uint16_t>(arg[arg_idx]) & 0x00ff)
    //       As they say back in Perl-land, "TMTOWTDI!", and in this case it
    //       amounts to the C++ version of Perl "line noise" no matter which
    //       way you slice it. :)
    std::cout << ' ' 
              << std::setw(2) 
              << static_cast<uint16_t>(static_cast<uint8_t>(arg[arg_idx])); 
  
  std::cout << '\n';

  // Restore the original fill char and go back to decimal mode
  std::cout << std::setfill(orig_fill_char) << std::dec;

使用 MBCS 字符集设置构建为基于 64 位控制台的应用程序,运行上述代码:

testapp.exe 0’s

...,并产生以下输出:

Argument: 0s length: 3
Argument bytes: 30 92 73

所以,它 Windows,毕竟,至少部分。我需要构建这个应用程序的 UNICODE 字符集版本,看看我得到了什么。

关于如何一劳永逸地解决这个问题的最终更新

感谢 Eryk Sun 的 cmets 到 ikegami 接受的答案和该答案中的链接,我找到了最好的解决方案,至少在 Windows 10 方面。我现在将概述要遵循的具体步骤强制 Windows 将命令行参数以 UTF-8 格式发送到 Perl:

需要将清单添加到 perl.exe 和 wperl.exe(如果您使用它),它告诉 Windows 在执行 perl.exe 应用程序时使用 UTF-8 作为活动代码页 (ACP)。这将告诉 Windows 将命令行参数作为 UTF-8 而不是 CP1252 传递给 perl。

需要做出的改变

创建清单文件

转到您的perl.exe(和wperl.exe)的位置,并在该(...\bin)目录中创建一个包含以下内容的文件,将其命名为perl.exe.manifest

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <assemblyIdentity type="win32" name="perl.exe" version="6.0.0.0"/>
  <application>
    <windowsSettings>
      <activeCodePage
        xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings"
      >UTF-8</activeCodePage>
    </windowsSettings>
  </application>
</assembly>

如果您还想修改wperl.exe,请将上述文件perl.exe.manifest 复制到wperl.exe.manifest 并修改该文件,替换assemblyIdentity 行:

  <assemblyIdentity type="win32" name="perl.exe" version="6.0.0.0"/>

with(注意分配给name属性的值从perl.exe更改为wperl.exe):

  <assemblyIdentity type="win32" name="wperl.exe" version="6.0.0.0"/>

在可执行文件中嵌入清单

下一步是获取我们刚刚创建的清单文件并将它们嵌入到各自的可执行文件中。在执行此操作之前,请务必备份原始可执行文件,以防万一!

清单可以嵌入到可执行文件中,如下所示:

对于perl.exe

mt.exe -manifest perl.exe.manifest -outputresource:perl.exe;#1

对于wperl.exe(可选,仅当您使用wperl.exe 时才需要):

mt.exe -manifest wperl.exe.manifest -outputresource:wperl.exe;#1

如果您还没有 mt.exe 可执行文件,可以在 Windows 10 SDK 中找到它,目前位于:Download Windows 10 SDK at developer.microsoft.com

基本测试和使用

进行上述更改后,UTF-8 命令行参数变得超级简单!

获取以下脚本,simple-test.pl:

use strict;
use warnings;
use v5.32; # Or whatever recent version of Perl you have

# Helper subroutine to provide simple hex table output formatting
sub hexdump

  my ($arg)=@_;
  sub BYTES_PER_LINE 16; # Output 16 hex pairs per line

  for my $chr_idx (0 .. length($arg)-1)
  
    # Break into groups of 16 hex digit pairs per line
    print sprintf('\n  %02x: ', $chr_idx+1/BYTES_PER_LINE)
      if $chr_idx%BYTES_PER_LINE==0;
    print sprintf('%02x ',ord(substr($arg,$chr_idx,1)));
  
  say '';


# Test app code that makes no mention of Windows, ACPs, or UTF-8 outside
# of stuff that is printed. Other than the call out to chcp to get the
# active code page for informational purposes, it is not particularly tied
# to Windows, either, as long as whatever environment it is run on
# passes the script its arg as UTF-8, of course.
my $arg=shift @ARGV or die 'No argument present';

say "Argument: $arg";
say "Argument byte length: $\length($arg) bytes";
print 'Argument UTF-8 data bytes in hex:';
hexdump($arg);

让我们测试我们的脚本,确保我们在 UTF-8 代码页 (65001) 中:

v:\videos>chcp 65001 && perl.exe simple-test.pl "Работа с ????’???? vis-à-vis 0's using UTF-8"

输出(假设您的控制台字体可以处理特殊字符):

Active code page: 65001
Argument: Работа с ????’???? vis-à-vis 0's using UTF-8
Argument byte length: 54 bytes
Argument UTF-8 data bytes in hex:
  00: d0 a0 d0 b0 d0 b1 d0 be d1 82 d0 b0 20 d1 81 20
  10: f0 9d 9f 98 e2 80 99 f0 9d 99 a8 20 76 69 73 2d
  20: c3 a0 2d 76 69 73 20 30 27 73 20 75 73 69 6e 67
  30: 20 55 54 46 2d 38

我希望我的解决方案能帮助遇到此问题的其他人。

【问题讨论】:

"92" 与我能找到的任何字符集中的引号都不对应。但是您肯定已经将 U+2019 放在了您的问题中。很奇怪。 Windows 原生是 UTF-16。如果 Perl 支持 UTF-8 用于命令行参数、环境变量和控制台 I/O,那么它是通过 UTF-16 和 UTF-8 之间的转码来实现的。一个例外是控制台输出代码页在 Windows 8+ 中使用 UTF-8 (65001),但将输入代码页设置为 UTF-8 仅限于 7 位 ASCII;非 ASCII 字符被读取为空字节。为 Windows 控制台输入和输出支持 UTF-8 的唯一可靠方法是使用宽字符 API(例如 ReadConsoleWWriteConsoleW)并在 UTF-16 和 UTF-8 之间进行转码。 Python 实现了这一点。有 Perl 吗? 编写了一个 C++ 测试应用程序来进一步测试正在发生的事情。至少对于 MBCS 字符集控制台应用程序,我看到了相同的不当行为(独立于 Perl)。打算尝试创建一个 UNICODE 字符集版本,看看我得到了什么结果。 添加了对 microsoft link ikegami 在他的回答中提出的想法的总结。 【参考方案1】:

每个处理字符串的 Windows 系统调用都有两种类型:使用活动代码页(也称为 ANSI 代码页)的“A”NSI 版本,以及使用 UTF-16le 的“W”ide 版本。[1] Perl 使用所有系统调用的A 版本。这包括获取命令行的调用。

ACP 是硬编码的。 (或者也许 Windows 在安装过程中要求系统语言并以此为基础?我不记得了。)例如,我的系统上是 1252,我无法更改它。值得注意的是,chcp 对 ACP 没有影响。

至少,直到最近还是如此。 2019 年 5 月对 Windows 的更新增加了 change ACP 通过其清单在每个应用程序的基础上的能力。 (该页面表明可以更改现有应用程序的清单。)

chcp 更改控制台的 CP,但不会更改 A 系统调用使用的编码。将其设置为包含 的代码页可确保您可以输入,并且Perl 可以打印出(如果编码正确)。[2] 因为 65001 包含,你做这两件事没有问题。

选择控制台的 CP(由chcp 设置)对 Perl 如何接收命令行没有影响。因为 Perl 使用 A 版本的系统调用,命令行将使用 ACP 进行编码,而不管控制台的 CP 和 OEM CP。


基于 被编码为92 的事实,您的系统似乎也使用1252 作为其活动代码页。因此,您可以通过以下方式解决您的问题:

use Encode qw( decode );

my @ARGV = map  decode("cp1252", $_)  @ARGV;

请参阅this post 了解更通用和可移植的解决方案,该解决方案还将适当的编码/解码层添加到 STDIN、STDOUT 和 STDERR。


但是,如果您想支持任意 Unicode 字符而不是仅限于系统 ACP 中的字符,该怎么办?如上所述,您可以 change perl 的 ACP。将其更改为 650001 (UTF-8) 将使您能够访问整个 Unicode 字符集。

除此之外,您需要使用系统调用的W 版本从操作系统获取命令行并对其进行解析。

虽然 Perl 使用 A 版本的系统调用,但这并不限制模块做同样的事情。他们可能会使用W 系统调用。[3] 所以也许有一个模块可以满足您的需求。如果没有,我之前写过 code 就是这样做的。


非常感谢@Eryk Sun 在 cmets 中提供的意见。


可以使用Win32::GetACP()获取ACP。 可以使用Win32::GetOEMCP()获取OEM CP。 控制台的CP可以通过Win32::GetConsoleCP()/Win32::GetConsoleOutputCP()获取。
    SetFileApisToOEM 可用于更改某些 A 系统调用对 OEM CP 使用的编码。[3] 控制台的 CP 默认为系统的 OEM CP。这可以通过更改HKCU\Console\&lt;window title&gt; 注册表项的CodePage 值来覆盖,其中&lt;window title&gt; 是控制台的初始窗口标题。当然,它也可以使用chcp 和它进行的底层系统调用来覆盖。 值得注意的是,请参阅Win32::Unicode。

【讨论】:

进程 OEM 代码页(即 CP_OEMCPGetOEMCP)默认为系统 OEM 代码页。在 Windows 10 中,ANSI (CP_ACP) 和 OEM 代码页可以在系统级别或通过 "activeCodePage" setting 在应用程序清单中设置为 UTF-8。 大多数多字节 API 函数使用 [A]NSI 代码页(例如 CreateProcessA),但文件系统 API 可以通过 SetFileApisToOEM 切换到 OEM。控制台的输入或输出代码页(分别为 GetConsoleCPGetConsoleOutputCP)默认为 conhost.exe 进程的 OEM 代码页,除非在可以在注册表项中为初始窗口标题设置的“CodePage”值中指定不同的值在“HKCU\Console\”。 如果 OEM 在系统级别设置为 UTF-8,它会在控制台中作为默认代码页被破坏。控制台仍然不支持带有多字节ReadFileReadConsoleA 的输入代码页(即GetConsoleCP)的UTF-8,在这种情况下,它会将非ASCII 字符读取为空字节。系统 OEM 设置为 UTF-8 时,必须为需要非 ASCII 多字节输入的每个控制台窗口设置“CodePage”值。这不会影响使用控制台的宽字符 (UTF-16) API(例如 ReadConsoleW)的应用程序,例如 Python 和 PowerShell 中的普通控制台 I/O。 @Eryk Sun,我不知道它使用什么,但是如果 Perl 设置为 65001,它能够从控制台读取 UTF-8。 @Eryk Sun,天哪! SetFileApisToOEM 很有道理!但它会影响GetCommandLineA吗?我猜它不会因为GetCommandLineA 在技术上不返回文件名。此外,调用SetFileApisToOEM 的脚本肯定会为时已晚,无法影响@ARGV【参考方案2】:

use utf8 只让 Perl 接受 UTF-8 语法,就像变量名和函数一样。其他所有内容都保持不变,包括@ARGV。所以my $arg=shift @ARGV 正在读取原始字节。

Unicode in Perl is complicated。最简单的做法是使用use utf8::all,而不是为语法、所有文件句柄、@ARGV 和其他所有内容打开 UTF-8。

【讨论】:

我认为您的意思是use utf8 qw(:all);,但这并没有改变任何事情。我认为 perl 命令行开关 -CSDA 做了完全相同的事情,我确实尝试过,没有任何改变。 @MichaelGoldshteyn 不,我的意思是utf8::all。有一个链接和一切。但是,您是对的,-CSDA 应该这样做。无论如何,给 utf8::all 一个机会。 OK,安装 utf8::all,得到:UTF-8 "\x92" does not map to Unicode at c:/perl/site/5.32.0/lib/utf8/all.pm line 231 哦,好吧...回到第一格。 @MichaelGoldshteyn 这表明您的输入是 CP1252 \x92,而不是 UTF-8 \x2019 根据我问题底部的 hackish 代码计算出这么多,它从 CP1252 -> UTF-8 进行转换,并产生正确的输出。但是,最大的问题是:为什么?特别是考虑到 chcp 正确报告 65001 并且我的 ShowArgs 工具正确显示命令行 arg 的 UTF-8 数据?

以上是关于在 Windows 上解析 UTF-8 命令行参数的这个奇怪问题的原因是啥?的主要内容,如果未能解决你的问题,请参考以下文章

27.Go 解析命令行参数

Windows 7 中文版命令行如何修改字符编码为UTF-8?

如何在C++中解析命令行参数

如何在 Perl 中将命令行参数视为 UTF-8?

在windows中,如何使用cmd命令行窗口正确显示编码为utf-8格式的文字

Linux getopt/getopts解析命令行参数教程