2021年4月24日土曜日

Tap.NET 0.2.0リリース

Tap.NETをリリースしてから3年近く経ちましたが、最近個人的にまたこの仮想NICを使う機会が増えて、しばしば自分で使っています。

そうすると初めていろいろと不満が出てくるわけです。特にクリティカルなのは、世の中はとっくに.NET Frameworkはオワコン化して.NET Coreに移り変わっているというのに、このライブラリは.NET Frameworkのしかもx86ビルドを要求してきます。どう見ても過去の遺物です。

というわけで、少なくとも.NET Core 3.1以上のAnyCPUビルドで使えるようにしたいなーと思い、エッセラホイサと移植作業を行いました。C++/CLIからC#で焼き直しましたので、ほぼ完全に焼き直しています。その際、いくつか破壊的変更もしています。ご了承下さい。

使い方

Nugetからインストールしてください。.NET Core 3.1のパッケージしか入れていませんが、今後作るソフトではこれだけ入っていれば困らないでしょう。

TAP.NET 0.2.0

インスタンス生成方法

旧バージョンではGUIDを与えて初期化するコンストラクターしかありませんでしたが、今バージョンからは無引数のコンストラクラーを用意し、ワンタッチでデバイスを開けるようにしました。今まで入れていなかったのは、単にC++/CLIで実装するのが面倒だっただけですが、C#に移植することでこういうのも簡単にできるようになりました。

using var tap = new Tap();

送信方法

これは以前と何ら変わりません。

using var tap = new Tap();

var ethernetPacket = new EthernetPacket(PhysicalAddress.Parse("90-90-90-90-90-90"), PhysicalAddress.Parse("80-80-80-80-80-80"), PacketDotNet.EthernetType.None) {
    PayloadPacket = new IPv4Packet(IPAddress.Parse("192.168.1.1"), IPAddress.Parse("192.168.1.2")) {
        PayloadPacket = new UdpPacket(123, 456),
    },
};
tap.SendData(ethernetPacket.Bytes);

(これをコンパイルするにはPacket.NETが必要です)

Packet.NETの力でUDPパケットを作りだし、そのバイト列をSendDataメソッドに渡していますそれだけです。もちろんここにはEthernetパケットを渡してあげる必要があります。

受信方法

こちらは前バージョンから少し変えました。

using var tap = new Tap();

tap.DestinationMacFilter = PhysicalAddress.Parse("FF-FF-FF-FF-FF-FF");
tap.EthernetTypeFilter = TapDotNet.EthernetType.IPv4;

tap.DataReceived += (sender, e) => {
    Console.WriteLine(Packet.ParsePacket(LinkLayers.Ethernet, e.Data));
    Console.WriteLine();
};
tap.StartReceiving();

(これをコンパイルするにもPacket.NETが必要です)

データの受信を開始するにはStartReceiving()メソッド、停止するにはStopReceiving()メソッドを呼ぶように変更しています。ですがそんなに違和感のある仕様ではないでしょう。

データを受信したらDataReceivedイベントが発生しますが、イベントが大量に発生するとEventArgsのインスタンス化等でそれなりにコストがかかるため、フィルター機能を用意しています。Ethernetフレームのみを対象のフィルターで、DestinationMacFilter、SourceMacFilter、EthernetTypeFilterに何かしらの値を入れることで、その値を含むパケットを受信したときのみイベントが発生するようになります。これらのフィルターをnullにするとその項目は素通しとなります。EthernetTypeはFlagsの列挙体ですので、複数指定も可能です。細かな仕様が若干違う点もありますが、Ethernetフレームに対してフィルターを掛けられるのは以前のバージョンと同じです。

中身の実装の話

さて、C#/.NET Coreに移植するにあたっていくつか出てきた話を。

P/Invoke

C++/CLIからC#への移植と言っても、基本的にはC++/CLIから直接呼び出していたWind32APIをP/Invokeという機能でC#から呼び出すようにするだけです。TAP-Windowsを利用するためには、CreateFileW関数でデバイスをオープン、DeviceIoControl関数で有効化し、WriteFile関数でデータ送信、ReadFile関数でデータを受信、最後はCloseHandle関数でデバイスをクローズしますので、最低限この5つの関数をC#から使えるようにする必要があります。

internal class Kernel32
{
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    internal static extern IntPtr CreateFileW(
        [MarshalAs(UnmanagedType.LPWStr)] string filename,
        [MarshalAs(UnmanagedType.U4)] FileAccess access,
        [MarshalAs(UnmanagedType.U4)] FileShare share,
        IntPtr securityAttributes,
        [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
        [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes,
        IntPtr templateFile);


    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool CloseHandle(IntPtr hObject);

    [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    internal static extern bool DeviceIoControl(
        IntPtr hDevice,
        uint ioControlCode,
        [MarshalAs(UnmanagedType.LPArray)] [In] byte[] inBuffer,
        int ninBufferSize,
        [MarshalAs(UnmanagedType.LPArray)] [Out] byte[] outBuffer,
        int noutBufferSize,
        out uint bytesReturned,
        [In] IntPtr overlapped
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool WriteFile(
        IntPtr hFile, [In] byte[] lpBuffer,
        uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten,
        IntPtr lpOverlapped);

    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool ReadFile(
        IntPtr hFile, [Out] byte[] lpBuffer,
        uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead,
        IntPtr lpOverlapped);
}

その他後述しますが、IO中断絡みでの新規対応が必要でしたので、それ周りの関数も同様に準備する必要があります。

CreateFile関数ですが、System.IO.FileAccess列挙体などの値を直接使うようです。最初、Wind32APIで渡すDWORD値をそのまま入れたらうまくデバイスが開けなくてしばらく格闘していました。

// TAPデバイスを開く
hTap = Kernel32.CreateFileW(path, FileAccess.ReadWrite, FileShare.None, IntPtr.Zero, FileMode.Open, FileAttributes.System, IntPtr.Zero);
if(hTap.ToInt64() == -1)
    throw new InvalidOperationException("Cannot open the device");

Thread.Abort()メソッドの廃止

フレームワーク側の変更として、Thread.Abort()メソッドの廃止というものがありました。.NET Frameworkでは問題なく動作していましたが、.NET Coreではサポート外となるようです。

データの受信処理では、受信用のスレッドを生成し、そこでReadFileを呼び出してパケットを受信しています。今までは終了処理時にThread.Abort()メソッドで強制的にスレッドを停止させ終了していたのですが、その方法は使えなくなってしまいました。

その代替方法はCancellationTokenとのことですが、CancellationTokenはあくまでも処理の中断要求を指示して、別スレッドがそのフラグを見たときに中断処理を行うというものです。しかし、受信用スレッドではデータの受信が無ければReadFile関数から処理は帰って来ませんので、いつまで経ってもCancellationTokenのフラグを見ることができず終了できません。

そこで、Win32APIのCancelSynchronousIo関数を使います。これは、特定のスレッドで同期的にIO処理をしている際に、処理を中断させることが出きる関数です。この関数に与えるハンドルはIOデバイスのハンドルではなくスレッドのハンドルになるので注意が必要です。

public void StopReceiving()
{
    if(IsReceiving) {
        canceltokensource!.Cancel();

        // ReadFileの中断
        var thread = Kernel32.OpenThread(0x40000000, false, threadid);
        Kernel32.CancelSynchronousIo(thread);
        Kernel32.CloseHandle(thread);

        watcher!.Join();
        watcher = null;
        canceltokensource = null;
    }
}

void ReceiveThread()
{
    threadid = Kernel32.GetCurrentThreadId();

    var buffer = new byte[1518];

    while(!canceltoken.IsCancellationRequested) {
        if(Kernel32.ReadFile(hTap, buffer, (uint)buffer.Length, out var length, IntPtr.Zero)) {
            if(Filter(buffer, length)) {
                var data = new byte[length];
                Array.Copy(buffer, data, length);
                RaiseDataReceived(data);
            }
        } else {
            var error = System.Runtime.InteropServices.Marshal.GetLastWin32Error();
            if(error != 0 && error != 995) {
                RaiseErrorHappened("ReadFile Error", error);
                break;
            }
        }
    }
}

C#で生成したスレッドではWin32のスレッドIDを知るすべはありませんので、まずはスレッド立ち上げ時にGetCurrentThreadId関数でスレッドIDを保存しておきます。

スレッド終了時は、それをもとにスレッドハンドルを取得し、CancelSynchronousIo関数を呼び出してIOの中止指示を出します。

ReadFile関数は、IOが中止されるとfalseを返し、エラーコード995(ERROR_OPERATION_ABORTED)を残します。これは意図したエラーですので、エラーイベントには流さないように配慮しています。

これにより、ReadFile関数がパケットの受信を待機している最中でも、強制的にそれを中断させてスレッドを終了させることができるようになりました。


てなところで、.NET CoreのAnyCPUからTAP-Windowsを触れるようになりました。

今までずっとprereleaseモードで公開していたからかダウンロード数はあまり伸びていませんが、類似ライブラリは今のところありませんので(多分)、それなりにみんな使ってくれないかなーと期待しています。

えっ?わざわざ仮想NICに外からパケットを流し込みたい人なんてそうそういない?そんなあ。

2021年3月21日日曜日

Directory.CreateDirectory()を実行する前にディレクトリが作成可能かを確認する

ここ数日悩んでいた件です。

C#にはDirectory.CreateDirectory()というメソッドがあり、指定したパスにディレクトリを作ることができます。しかし、当然どんなところにでも作れるわけではなく、例えば存在しないドライブに書き込もうとした、管理者権限じゃないと書き込めないフォルダだった("C:\Program Files"など)、書き込み権限の無いフォルダだったなどの理由で、このメソッドは実行時に失敗する(例外を吐く)可能性があります。

そこで、実際にディレクトリを作るわけではなく、事前にディレクトリを作ることができるのかを確認する方法を模索していました。

 

結論を言います。

良いから「ディレクトリを1回作ってみて、作れたらそのディレクトリを削除する」という方法で片付けろ。

 

いろいろ調べていたのですが、これ以上良い方法が見つかりませんでした(逆に何かいい方法知っている人いたら教えてください…)。

フォルダの書き込み権限を確認する

一応、C#でもフォルダの書き込み権限を確認することはできます。ただし、Windows依存のコードになります。

var access = new DirectoryInfo(path).GetAccessControl();
var rules = access.GetAccessRules(true, true, typeof(NTAccount));

ただし、このGetAccessControl()はFileSystemAclExtensions.GetAccessControl()拡張メソッドで、Nuget経由でパッケージをインストールする必要があります。

このGetAccessRules()メソッドが返す値が、いわゆるフォルダを右クリック→プロパティ→セキュリティ→詳細設定で出てくるフォルダの読み書き権限に相当するものとなります。

今実行中のアプリケーションとして書き込めるかどうかを知りたいだけなのに、ファイルシステムの権限情報が全部出てくるのです。今の自分はこのどれに相当するのかを見分けなければなりません。暗礁に乗り上げてきた気がしてきました。

実行中のアプリケーションのプリンシパルを取得する

実行中のアプリケーションのプリンシパルを取得しようとしたら、WindowsIdentity.Current()を呼ぶことになります。

そのメソッドが返したWindowsIdentityインスタンスのNameプロパティを見ると現在実行中のユーザー名、Groupsプロパティを見ると自身が属するグループ名が出てきます。これらを前項のプリンシパルと突き合わせれば良いのかと思いきや、どうもそれは完全ではないようです。例えば、ネットワークドライブ上のフォルダはそれでうまくいきませんでした。

 

そもそも、OS上では充分に抽象化された「ディレクトリ」いう概念でプログラムを書いていたのに、一気に具体的なファイルシステムがどうとか、Windowsのアクセスコントロール機能がどうとか、そういうレイヤーのプログラムを書くことを強いられてしまいます。世の中には多種多様な記録媒体やファイルシステムがあり、それらすべてについて正確に書き込みができるかどうかを判断するコードをこのレベルで書くのはそう簡単にできるものではありません。

ですので、最初に書いた通り、ひとまずディレクトリを作ってみて確かめるのがやはり一番いい方法という結論に私は達しました。 

ディレクトリを作って削除するのも、実際のファイルシステムを汚している感があったり、後のDeleteで誤ったフォルダを削除してしまわないように慎重にコードを書く必要があったり、そもそも例外発生前提でプログラムを書く必要があるのでそれなりに神経を使う必要があったりと、こちらも何かと苦労があります。ですから、書き込み権限を事前に確認して終わらせようという発想になっていたはずです。

はあ、こういうの、標準でDirectory.CanCreateDirectory()みたいなメソッドが用意されてワンタッチに判定してもらえるようになったりしないのかなあ。しないんだろうなあ。抽象化(=様々なOS/ファイルシステムで正常に動作するように)するのが難しいから用意されていないんだろうなあ…。

何かいい方法ご存知の方いれば教えてください。

2021年3月14日日曜日

SliderのAutoToolTipPlacementをカスタマイズする

SliderのAutoToolTip

WPFのSliderにはAutoToolTipPlacementというプロパティがあり、これにNone以外の値を設定することで、Sliderを動かしている最中に自動的につまみ(Thumb)に連動するツールチップを表示させ、値をリアルタイムに確認できるようになります。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" />

非常に手軽で強力ですね。

しかし、これが案外WPFらしからぬ機能なのです。というのもWPFはXAMLの力でUIがかなり柔軟に書けるのが特徴なはずなのですが、このツールチップのテンプレートはおろか、数値のフォーマットすら指定できません。申し訳程度にできるのは、AutoToolTipPrecisionプロパティを使って小数点以下の表示桁数を指定する程度です。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="2" />

そのため、例えばDateTimeのような、単純な数値以外を扱うSliderを作っただけで詰んでしまいます。

【MainWindow.xaml】

<Window
    x:Class="SliderTest.Views.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
    xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
    xmlns:v="clr-namespace:SliderTest.Views"
    xmlns:vc="clr-namespace:SliderTest.Views.Converters"
    xmlns:vm="clr-namespace:SliderTest.ViewModels"
    Title="MainWindow"
    Width="525"
    Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Window.Resources>
        <vc:DateTimeTickConverter x:Key="DateTimeTickConverter" />
    </Window.Resources>

    <i:Interaction.Triggers>
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodName="Initialize" MethodTarget="{Binding}" />
        </i:EventTrigger>

        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction />
        </i:EventTrigger>
    </i:Interaction.Triggers>

    <Grid >
        <Slider VerticalAlignment="Center" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="0"
                Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
                Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
                Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" />
    </Grid>
</Window>

【DateTimeTickConverter.cs】

[ValueConversion(typeof(DateTime), typeof(long))]
public class DateTimeTickConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is DateTime val)
            return val.Ticks;
        else
            return DependencyProperty.UnsetValue;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is double val)
            return new DateTime((long)val);
        else
            return DependencyProperty.UnsetValue;
    }
}

【MainWindowViewModel.cs】

public class MainWindowViewModel : ViewModel
{
    public void Initialize()
    {
        var now = DateTime.Now;
        Minimum = now;
        Value = now;
        Maximum = now + TimeSpan.FromDays(1);
    }


    public DateTime Minimum
    {
        get { return _Minimum; }
        set { 
            if(_Minimum == value)
                return;
            _Minimum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Minimum;


    public DateTime Maximum
    {
        get { return _Maximum; }
        set { 
            if(_Maximum == value)
                return;
            _Maximum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Maximum;


    public DateTime Value
    {
        get { return _Value; }
        set { 
            if(_Value == value)
                return;
            _Value = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Value;
}

何の変哲もない、ただ単にDateTimeをConverter経由でSliderにバインディングするだけのプログラムです。

なのに、このようにツールチップに表示される値はDateTimeのTicksの値になってしまい、アプリユーザーにとっては無意味な数の羅列となってしまうのです。変換しているので当然なのですが、それはもちろんDateTimeがそのままではSliderのMinimum/Maximum/Valueにバインディングできないからです。

通常のWPF脳ならば、BindingのStringFormatみたいなのは無いかなとか、DataTemplateでToolTipをカスタマイズできないかなとか考えるわけです。が、どんなに探してもSliderにあるこの自動ツールチップに設定できるパラメーターは「ツールチップの表示位置」と「数値の小数点以下の桁数」のみなのです。困った困った。

先人の知恵

さて、本件ちょっとググると、まあ10年以上前にこの解決策を提示してくれている人はすぐに見つかるわけです。

Modifying the auto tooltip of a Slider 

比較的シンプルなコードで作っています。Sliderクラスを継承したFormattedSliderクラスを作り、そこにAutoToolTipFormatというプロパティを作る作戦ですね。

ただし、少しコードを読んでいくとビビります。というのも、Sliderクラス内部のprivateフィールドである「_autoToolTip」をリフレクションで無理矢理取り出し、それを改造することで数値のフォーマット化を実現しているのです。 うーん、さすがに無理矢理すぎる気が。現実的にはあまり問題無いのかもしれませんが、例えば.NETの内部実装のリファクタリングで_autoToolTipという名前が変えられただけで正常に動かないソフトになってしまいます。

それが許せる人はこれを使えば良いかもしれませんが、個人的にはちょっと禁忌に触れた黒魔術感があって嫌だなー…。

Behaviorによる実装

さて、やっと本題に入ります。私は黒魔術ではなく正攻法で攻めていきます。

上のようにSliderクラスを継承した新しいクラスを作っても良いのですが、WPFの場合は強力なカスタマイズ機能によりほとんどそのような手順は不要です。既存のクラスに添付して振る舞いを変えさせたければBehaviorでしょう。

【SliderThumbToolTipBehavior.cs】

public class SliderThumbToolTipBehavior : Behavior<Slider>
{
    ToolTip? tooltip;

    protected override void OnAttached()
    {
        base.OnAttached();

        AssociatedObject.AddHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.AddHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.AddHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();

        AssociatedObject.RemoveHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.RemoveHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.RemoveHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    void Thumb_DragStarted(object sender, DragStartedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb) {
            if(tooltip == null) {
                tooltip = new ToolTip();
                tooltip.Placement = PlacementMode.Custom;
                tooltip.PlacementTarget = thumb;
                tooltip.CustomPopupPlacementCallback = ToolTip_CustomPopupPlacementCallback;
            }

            thumb.ToolTip = tooltip;
            tooltip.Content = ToolTipTemplate.LoadContent();
            tooltip.IsOpen = true;
            TooltipReposition();
        }
    }

    void Thumb_DragDelta(object sender, DragDeltaEventArgs e)
    {
        TooltipReposition();
    }

    void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb && tooltip != null) {
            tooltip.IsOpen = false;
        }
    }

    void TooltipReposition()
    {
        if(tooltip != null) {
            double temp;
            if(AssociatedObject.Orientation == Orientation.Horizontal) {
                temp = tooltip.HorizontalOffset;
                tooltip.HorizontalOffset += 0.125;
                tooltip.HorizontalOffset = temp;
            } else {
                temp = tooltip.VerticalOffset;
                tooltip.VerticalOffset += 0.125;
                tooltip.VerticalOffset = temp;
            }
        }
    }

    CustomPopupPlacement[] ToolTip_CustomPopupPlacementCallback(Size popupSize, Size targetSize, Point offset)
    {
        CustomPopupPlacement? ret = null;

        switch(Placement) {
            case AutoToolTipPlacement.TopLeft:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, -popupSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(-popupSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
            case AutoToolTipPlacement.BottomRight:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, targetSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(targetSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
        }

        if(ret != null)
            return new CustomPopupPlacement[] { ret.Value };
        else
            return Array.Empty<CustomPopupPlacement>();
    }



    public AutoToolTipPlacement Placement
    {
        get { return (AutoToolTipPlacement)GetValue(PlacementProperty); }
        set { SetValue(PlacementProperty, value); }
    }
    public static readonly DependencyProperty PlacementProperty =
        DependencyProperty.Register(nameof(Placement), typeof(AutoToolTipPlacement), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(AutoToolTipPlacement)));


    public DataTemplate ToolTipTemplate
    {
        get { return (DataTemplate)GetValue(ToolTipTemplateProperty); }
        set { SetValue(ToolTipTemplateProperty, value); }
    }
    public static readonly DependencyProperty ToolTipTemplateProperty =
        DependencyProperty.Register(nameof(ToolTipTemplate), typeof(DataTemplate), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(DataTemplate)));
}

【MainWindow.xaml】(抜粋)

<Slider VerticalAlignment="Center" 
        Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
        Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
        Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" >
    <i:Interaction.Behaviors>
        <vb:SliderThumbToolTipBehavior Placement="TopLeft" >
            <vb:SliderThumbToolTipBehavior.ToolTipTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Value, StringFormat=HH:mm:ss.fff}" />
                </DataTemplate>
            </vb:SliderThumbToolTipBehavior.ToolTipTemplate>
        </vb:SliderThumbToolTipBehavior>
    </i:Interaction.Behaviors>
</Slider>

まず、OnAttatchedで3つの添付イベントをリッスンし、つまみを握ったときにツールチップ表示、動かしたときにツールチップ位置変更、離したときにツールチップ非表示を行います。ミソは、このイベントのe.OriginalSourceにつまみ(Thumbクラス)のインスタンスが入っていると言うことですね。

このビヘイビア自体には2つの依存関係プロパティを用意しています。1つ目はPlacementで、これはSlider本家のAutoToolTipPlacementと同じ役目なので説明はいらないでしょう。2つ目はToolTipTemplateで、ツールチップの中身に表示するものを定義するテンプレートです。これを用意することにより、「値のフォーマット化」にとらわれず自由自在に表示内容を設定できるようにしています。

ツールチップの位置を変える方法ですが、残念ながら直接的に再計算を要求させる方法(ToolTip.CustomPopupPlacementCallbackを呼ばせる方法)は無いようです。ですので、不本意ながら「HorizontalOffset/VerticalOffsetを変更して戻す」という手段を用いることで再計算させています。2度再計算させちゃうので効率悪いんですがね。

XAMLではSlider本家のAutoToolTipPlacementは使わず、ビヘイビアのほうのPlacementを設定します。あとは、ToolTipTemplateにツールチップの内容を設定するだけです。

これで見事ツールチップに意図した通りのものを表示できるようになりました。めでたしめでたし。

2020年9月14日月曜日

電子負荷装置を自作する

 さて、今回は(も?)ブログのタイトルに反してプログラミングは全く関係ない記事です。すみません。

1年ほど前、自作で電源装置を作りました。電源装置を作ったら当然負荷テストをしたくなりますよね。ということで、当時はセメント抵抗を何種類か買ってきてテストしていたのですが、抵抗値を変えようと思ったら繋ぎ変えが必要ですので、徐々に負荷を変化させていくということができません。思えば、この時から電子負荷装置が欲しかったのですが、ひとまずは電源装置も安定して動作しておりましたので特にその熱は一旦引いていました。

ですが、最近、日本橋の共立電子に行ったところ、 特設コーナーでスイッチング電源が安売りしていました。5V6Aが550円、12V9Aが1200円など、比較的高出力なものもかなり手ごろな値段で売っております(8月末~9月頭にかけての情報です。特売ですので、いつ行っても買えるとは限りません)。

普段は弱電ばっかり扱っている私ですので、たった数十Wでもパワみを感じて衝動買いしてしまいました。ですが、これだけ高出力な電源の性能を十分に発揮できるだけの負荷が手元にありません。

ここから、1年ほど前の「電子負荷装置」の熱が自分の中で再燃し、せっかくなので作ることを決意しました。

回路設計

電子負荷装置もいろいろなものがあります。商用レベルだと、数百Wとか数kWとかを消費でき、その上、その電力を電力系統に回生するようなものまで売っています。そういうものを自作するのは流石にレベルが高すぎますので、ここは簡単に数十W程度を消費できる単純な負荷を用意することとしました。

モードとしては、定電流モードと定抵抗モードを想定します。定電流モードは入力電圧にかかわらず電流を一定に保つモード、定抵抗モードは入力電圧に比例した電流を流すモードです。名前の通りですね。

この回路は、かなり簡単に作ることができます。オペアンプの基礎が分かっていれば、下記回路には何も説明はいらないでしょう。

たいてい回路図には現れませんが、オペアンプのLM358の電源は12V側から取っています。LM358は2回路入りなので、使わない側は周辺回路と容量結合とかして発振しないよう、適切に接地等してあげましょう。

SW1が定抵抗モード/定電流モードを切り替えるスイッチ、SW2がレンジのスイッチですね。

定電流モードで最大約10Aを流すことを想定しているので、オペアンプの+入力が最大約1Vになるよう抵抗値を調整しました。電源側ににさらに条件を付ければ外部電源無しでも作れますが、それは嫌だったのに加え、電源が必要な電圧/電流計を使用することにしたので外部電源を使うようにしました。

熱設計

電圧、電流、許容損失

さて、上記の通り回路は超シンプルで簡単です。電子負荷装置を作る上で最も難しく、コストもかさむのは熱処理です。

例えばサンケン電子製の2SK3711のデータシートを見ると、定格電圧が60V、定格電流が70Aです。

2SK3711のデータシートより引用)

これだけ見ると、12V10Aくらい余裕のよっちゃんに見えます。

ただ、電子負荷装置は、この12V10AすべてをこのFETで受け止めることになります。たいていのスイッチング用途は何か別の負荷がドレインにつながっていて、それをFETでON/OFFするだけなので、FET自体にかかる電圧は低く、FETそのもので消費する電力もそこまで大きくはなりません。しかし、今回の用途ではすべての電力をFETで受け止めることになります(大事なことなので2度言いました)。

ですが、許容損失は130Wと、12V10A=120Wの損失を発生させても大丈夫そうです。ただし、チャネル温度を150℃以下に抑えられるならね。

そう、一番厳しいのはここ、チャネル温度なのです。定格電流にも定格電圧にも達していなくても、チャネル温度が150℃を超えたらFETが焼け切れてしまうのです。

熱抵抗

熱抵抗というパラメーターがあります。単位はK/Wです。熱伝導率の逆数ですね。「何Wの熱を流すのに何K温度の差が付くか」という物理量です。電気抵抗も「何Aの電流を流すのに何Vの電位差が付くか」という物理量ですね。そのアナロジーで考えると簡単です。

例によって2SK3711のデータシートを見ると、過渡熱抵抗θj-cが定常状態で0.95℃/Wくらいです。過渡熱抵抗と言うとわかりにくいですが、下付き文字の「j-c」は「ジャンクション-ケース間」の意味と思われます。すなわち、チャネルで発生した熱をケース(=FETのパッケージ)に伝えるのにどれくらい温度差が付くか、ということですね。

0.95℃/Wということは、たとえば120Wの損失を作るとチャネルとケースに120×0.95=114℃の温度差が付きます。 超強い放熱器を付けて、ケースを30℃に抑えたとしても、チャネル温度は143℃となり絶対最大定格のぎりぎりとなります。

一方で、例えば東芝製TK70J20Dならば、チャネル-ケース間熱抵抗が最大0.305℃/Wです。先ほどと同じく120Wを消費したとしても、チャネルとケースは36.6℃しか差が付かないということになりますね。これならば少し弱い放熱器を付けて、ケースが110℃くらいまで上昇してしまっても絶対最大定格を超えることはありません。さすが、値段が高いだけありますね。放熱特性が高性能です。

このように、大きな電力を消費させるFETの場合、電流や電圧といった特性よりも何よりも、このようにチャネル温度が最も設計上のボトルネックとなります。

ケース選定

ヒートシンク付きケース

さて、放熱がどれほど重要か、という話をしましたが、そこで重要になってくるのが放熱器です。例えば共立電子ではジャンク扱いでクソデカ放熱器が売っていたりしますが、ちょっとこれはネタすぎてカッコよくないですよね。

今回はタカチ製のヒートシンクアルミケースのEXHシリーズを採用しました。パネルの大きさ、熱抵抗などからEXH14-5-19BBを選択しました。熱抵抗2.27℃/Wです。ちなみに黒アルマイト品のほうが値段が少々高くつきますが、放熱器は黒のほうが黒体放射で冷却性能がやや高いらしいです。ほんとかよ。

このケースだけで値段が5000円程度してしまいます。回路だけだと1000円も出せば十分に組み立てられてしまうのに、ほんと、「電子負荷装置の自作は放熱機構の自作である 」という感じですね。

なお、このケース、放熱器側にナット用の溝があり基板を取り付けることができます。表面にネジ穴を出さないスマート設計ですね。

カタログPDFより引用)

アルミ板を用意し、そこにFET、シャント抵抗、基板を固定しケースに取り付けました。良い感じです。

アルミ板と放熱器の間の空間

さて、この取り付け方、カタログPDFに書いてある通りなのですが、いかんせん、放熱器への熱が流れるルートが少なすぎます。アルミ板とケースが接しているのはナットのレール部分のみ、放熱器本体とアルミ板の間は空気です。空気はかなり熱抵抗が大きな素材です。これでは、効率よく熱を逃がせません。

そこで、間を熱伝導率の高いもので埋めることを考えました。調べていたところ、ワイドワーク製の放熱ゴムというものを見つけました。図面上は隙間が6mm程度ですので、12mm厚のものを購入しました。潰して半分くらいの厚さにできるかなと思いましたが、硬く無理だったので、ハサミでカットして使いました。

熱伝導率2.4W/m・Kなので、面積100mm×100mm(=0.01㎡)、厚さ6mm(=0.006m)とすると2.4×0.01/0.006=4W/Kになります。逆数を取れば熱抵抗で、0.25K/Wとなります。ケースの熱抵抗は2.27K/Wなので、足して2.5K/W程度となります。これで、そこそこの放熱環境を整えられたと言えるでしょう。

耐久テスト

自然冷却

さて、調子に乗って電流を流すと次々とFETが焼けていくんですねえ。さらに、チャネル温度が限界突破して焼けた場合、ドレイン―ソース間がショートモードで故障するらしく、あまり良くない状況になります。

最終的に、TK70J20Dを使って連続30W運転程度が落としどころとなりました。

チャネル―外気間の熱抵抗を3K/Wとすると、30Wで約90℃の上昇となります。気温25℃なら115℃ですね。理論値はこうですが、いろいろと挟んでいるものもあるはずですので、まあこの辺が現実的な解かと思います。

強制空冷

もっと冷やしたくなったら強制空冷ですね。ケースの上にサーキュレーターを置いて、ガンガンヒートシンクを冷やしてみました。

80Wまでなんとかもちました。チャネル温度と気温の温度差を125℃とすると熱抵抗1.56K/Wです。もうすでにこの時点でケースの自然冷却時の熱抵抗を下回っています。さすが空冷ですね。一説によると、熱抵抗が1桁以上変わるんだとか。

ちなみに、90Wにしたら焼けました(◞‸◟)

こうやってみると、TDPが100Wを超えるようなCPUも普通にありますが、その性能をフルに発揮するヒートシンクって実はなかなかに大変なものなんでしょうね。

まとめ

というわけで、電子負荷装置を自作しましたが、話の中心は電気的なところではなく熱的なところとなりました。まあそれも予定調和ですね。

前半のほうに少し触れた電力系統への回生機能を持った電子負荷装置は、単にエコだけでなく、このような強烈な放熱機構の必要性という面からも非常に重要なもの、ということですね。数百Wも放熱するような電子負荷装置を作るとしたら、一体どれだけ大きな放熱機構が必要になることやら。

 

おまけ

デカール貼り

今回、レンジとモードのスイッチを持っていて表記を作らないとわかりにくいことから、デカールを用意して貼り付けました。

小さいころから鉄道模型はやっていたのでインスタントレタリングこと「インレタ」は何度かやったことがあったのですが、デカールはたぶん初めてでした。

難しいですね。小さい文字なんか、器用に貼ろうとしてもどうしても傾いてブサイクになってしまいます。

殉職したFETの方々

今回、実際どの程度FETが持つのかということを知りたくて、FET焼く覚悟で高負荷をかけて実験しました。これらのFETの死は無駄ではなかった…と信じたいです。どうか、来世ではスイッチング電源にでもなって活躍されますように。

FET交換の工夫

最初はFETをはんだ付けしていたのですが、だんだんとそれ自体が面倒になってきます。だってすぐに焼けるんだもん。

ということで、端子台を用意し、はんだ付け不要でFETを交換できるようにしました。

2020年6月28日日曜日

System.Interactiveで快適LINQ生活

久しぶりにプログラム書いてたらライブラリの名前をすっかり忘れて探すのにだいぶ苦労したのでメモしておきます。

C#3.0で導入されたLINQも、少し痒いところに手が届かないことがあります。
例えばDistinctメソッドは基本的に要素本体でしか重複判定をすることができません。LINQならば、例えば以下のようなことしたいですよね。
var uniqueName = data.Distinct(p => p.Name);
何かのデータで同じ名前の重複を削除したいときはこう書きたいです。
実際には他のプロパティが違うデータでも同じ名前だったら1つを残して削除されちゃいますし、どれが削除されるのかは直感的にはわかりません。そういった不都合があってこのようなメソッドが作られなかったのかもしれませんが、でも実際問題使いたいことが結構あります。

他にも、例えばSelectManyメソッドは「配列の配列」をそのまま縦につなげて1つの配列にするのに便利ですが、その逆、すなわち配列をいくつかごとに分割して「配列の配列」にするメソッドはありません。

そんなもろもろの痒いところに手を届かせたライブラリは実はMicrosoft公式で存在します。


Nugetから入れれば、一発でDistinctメソッドにキー選択機能があるオーバーロードが追加されます。めでたしめでたし。ちなみに後者の「SelectManyの逆」はBufferメソッドです。

このライブラリはReactive Extensionsの一部です。まあ、LINQを配列のように「位置的に並んでいるもの」だけじゃなく「時系列的に並んでいるもの」にまで拡張させたのがReactive Extensionsですから、その流れでできた新しい便利な拡張メソッドをIEnumerable<T>用の拡張メソッドに逆輸入した形なのでしょう(たぶん)。

備忘録でブログに残しておいただけなので、今日はこれくらいで。

2020年6月24日水曜日

WPFでChromium埋め込みブラウザを使用する [CefSharp]

自分のソフトの中でブラウザを埋め込みたいことってありますよね。
WPFではWebBrowserコントロールが用意されていますが、とにかく使いにくい上に、IE7ベースで動作するという非常に残念なものです。IE11/Edgeに切り替えることもできなくはないようですが…レジストリをいじることになるのでアプリケーションとして使うというよりか、システムとして設定を変更することになってしまいます。
どうせならGoogle Chromeなどが代表的なChromiumブラウザ系統の組み込みブラウザを使いたいですよね。最近、IE系統に対応せずにChrome対応を謳うサイトも増えてきたことですし。まあ私はFirefox派なんですが。

そこで今回紹介するのがCefSharpというライブラリです。
Chromium Embedded Framework (CEF)というChromiumブラウザの表示部分のみを抜き出したのフレームワークがあり、これをC#からアクセスできるようになったものがCefSharpです。

導入

導入は至って簡単です。Nugetから入れることができます。
今回はWPFで使用するので、CefSharp.WPFを入れましょう。そうするとついでにCefSharp.Commonも入ります。
注意点ですが、Targetをx86かx64に指定してやる必要があります。Any CPUだと動きません。

使用

これも簡単です。CefSharp.Wpf.ChromiumWebBrowserをXAML上で定義してあげるだけです。
<Window x:Class="WebBrowserTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WebBrowserTest"
        xmlns:cef="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"
        mc:Ignorable="d"
        Title="MainWindow" Height="1080" Width="1920">
    <Grid>
        <cef:ChromiumWebBrowser Address="https://www.google.co.jp" />        
    </Grid>
</Window>
めでたくブラウザが表示されます。

Navigate

任意のURLのWebページに遷移したいことがありますよね。
標準のWebBrowserコントロールは厄介で、Sourceプロパティが依存関係プロパティではありません。そのため、バインディングをすることができず、ビヘイビアを使うなどして操作しないといけません。超面倒でした。
CefSharpはそこのところはちゃんと考えてあり、Addressプロパティにバインディングすることができます。
<Window x:Class="WebBrowserTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WebBrowserTest"
        xmlns:cef="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"
        mc:Ignorable="d"
        Title="MainWindow" Height="1080" Width="1920">
    <DockPanel>
        <TextBox DockPanel.Dock="Top" x:Name="Address" Text="https://www.google.co.jp"/>
        <cef:ChromiumWebBrowser Address="{Binding ElementName=Address, Path=Text, Mode=TwoWay}" />        
    </DockPanel>
</Window>
TextBoxのTextをTwoWayでバインディングすることで、いわゆるブラウザのアドレスバーのような形で使用することができます。

スクリプトを実行

せっかく自分のプログラム上でブラウザを動かしているのですから、何かしらブラウザ上でアクションを起こしたいですよね。
そんな人の為にExecuteScriptAsyncという拡張メソッドが用意されています。実行するにはChromiumWebBrowserのインスタンスが必要ですので、MVVMを維持しながら作るならばビヘイビア等を活用して作る必要がありますが、今回は簡単のために、ボタンを押したらコードビハインドでスクリプトを実行するプログラムを作ってみます。
<Window x:Class="WebBrowserTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WebBrowserTest"
        xmlns:cef="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"
        mc:Ignorable="d"
        Title="MainWindow" Height="1080" Width="1920">
    <DockPanel>
        <TextBox DockPanel.Dock="Top" x:Name="Address" Text="http://orteil.dashnet.org/cookieclicker/"/>
        <Button DockPanel.Dock="Bottom" Content="Earn a million cookies" Click="Button_Click" />
        <cef:ChromiumWebBrowser x:Name="browser" Address="{Binding ElementName=Address, Path=Text, Mode=TwoWay}" />        
    </DockPanel>
</Window>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        browser.ExecuteScriptAsync("Game.Earn(1000000)");
    }
}
こんなコードを用意すると、ボタンを押すだけでクッキーが100万個生産されます。
True NeverclickなんていうAchievementを解除してしまいました。

スクリプトを実行してその値をC#側で受け取りたいこともあると思います。
そういうときは、EvaluateScriptAsync拡張メソッドを使います。
private async void Button_Click(object sender, RoutedEventArgs e)
{
    var response = await browser.EvaluateScriptAsync("document.getElementById('cookies').innerText");

    if(response.Success && response.Result != null)
        MessageBox.Show(response.Result.ToString());
}
非同期メソッドであることに注意してください。
上のメソッドを実行すると、このように'cookies'というIDを持った要素のinnerTextプロパティの値を返してきます。
注意点として、返却値は配列と文字列はOKみたいですが、それ以外のオブジェクトはダメなようです。微妙に使いにくいですが…。


てなところです。
私自身も使い始めたばかりなので全然触れていない部分もありますが、まあ、少なくとも標準のWebBrowserコントロールよりかは使いやすそうです。
こういうの使って何とかC#のステージに引きずり下ろすより、とっととJavascriptをしっかり勉強してブラウザのアドオンとか作ったほうがいいのかなあ。

2020年5月4日月曜日

PICマイコンでBCD変換 その2

以前PICマイコンでBCD変換という記事を書きました。
久しぶりにまたBCD変換を実装しなければならない場面が出てきたので、そういえば昔そんな記事書いていたよなと思って自分のブログを読み返したのですが、そこで参照していた外部サイトがリンク切れを起こしていましたので、訪れた人にとっては理解しにくい記事になってしまっていました。

ですので、そのBCD変換 のアルゴリズムをこの記事にて詳しく説明します。
もしもBCD変換の意義とかそのあたりについてを知りたい人、もしくは具体的な実装を見たい人がいれば上のリンクの記事を見てください。

準備

このようなメモリ空間を用意します。
下の位に2進数、上の位にBCD変換された数が入るようにして、一続きの値になるようにします。以前の記事では、24bit整数型と共用体でビットフィールドを作成し、このようなメモリを実現しました。

さて、実際に計算をこの箱を使いながら試していきます。
今回は例として、「123」をBCD変換してみましょう。123は2進数で表現すると0111 1011です。





n進数

n進数のおさらいです。
普段我々が日常的に使っているのは10進数で、1桁に0~9の10個の数を使います。そしてその桁の数が10を超えたら次の位に1が加算されます。つまり、各桁の値が表しているのは$10^n$ずつの単位になります。
ですので、 $10^n$の位の数字を$d_n$と置くと、各桁の値を使って以下のように表せます。\[10^0d_0+10^1d_1+^2d_2+...\]なんら疑問のない表現ですね。多くの人にとってはくどすぎる解説にすら感じるでしょう。
この考え方は2進数でも同じです。2進数で各桁の値を$b_n$と置くと、2進数の数値は以下のように表せます。\[2^0b_0+2^1b_1+2^2b_2+...\]つまり、上の2進数表現をした123は、すなわち以下のような表現だということです。\[2^7\times0+2^6\times1+2^5\times1+2^4\times1+2^3\times1+2^2\times0+2^1\times1+2^0\times1\\=64+32+16+8+2+1\]さて、話を戻して、この2進数の各桁の数から値を得る式を少し変形してみましょう。\[2^0b_0+2^1b_1+2^2b_2+2^3b_3+2^4b_4+2^5b_5+2^6b_6+2^7b_7\\=b_0+2(b_1+2(b_2+2(b_3+2(b_4+2(b_5+2(b_6+2b_7))))))\]そんなに難しい変形ではないですね。ここで重要なのは、$b_7$を2倍してから$b_6$を足して、それを丸ごと2倍してから$b_5$を足して…という計算で2進数の各桁を数値に変換できるということです。このBCD変換はこの考え方が大きなヒントになります。

左シフト

さて、先ほどの話に戻って、準備で用意したメモリを使って処理をしていきます。
これに左シフト演算を8回行います。ただ単にシフトするだけでは「2進数」エリアにある数字が「BCD10の位」「BCD1の位」の8ビット分に移動するだけですが、ここでは、この「移動するだけ」のシフト演算に意味を見出していきます。
左シフトには「値を2倍にする」という意味合いがあります。10進数で表された数の各桁を一つ左の位に移動させたら値は10倍になりますよね。同じように、2進数でも2倍になります。

BCD1の位を中心に物事を考えると、1回目の左シフトでBCD1の位の最小桁に現れた「0」(下の画像で赤色のもの)は、残り7回のシフトで最終的に$2^7$がかかります。2回目のシフトで現れた「1」(下の画像で緑色のもの)は、残り6回のシフトで最終的に$2^6$がかかります。

このように、左シフトを1回ずつしながら値を動かしていくことで「2進数の各桁の値を倍々にしていって、各桁にふさわしい係数($2^n$)がかかるようにする」という操作をしているとみなすことができます。これは、先ほどの各桁の数から値を導く式とやっていることは同じです。

さて、この操作、メモリ空間上でやっているので2進数でしか物事が見えていませんが、別に入れ物の数の表し方がどうであれ、「2倍して2進数の次の桁を足す」という操作さえ8回繰り返してできれば、示す数値としては元の2進数と同じ意味合いになるはずです。
ですので、左側のBCD3桁分で、BCDに沿ったルールで「2倍して2進数の次の桁を足す」という処理を行っていけば、「BCDで表される元の2進数と同じ数値」が得られるわけです。

BCDとしての「2倍する」

さて、いよいよBCD変換の肝に入っていきます。今度は4bitずつ、「BCDの位」 単位で物事を見ていきます。
前章の最後にも少し触れましたが、BCDの位単位で物事を見ようとも、「2倍してから2進数の次の桁を足す」という計算さえできれば、元の2進数と同じ意味合いの数値が得られます。

ここでBCDで2倍するとはどういうことか、考えてみます。
それは、まぎれもなく左シフトで実現できます。ただし、2進数とは違い、左シフトだけがすべてではありません。BCDの桁は4bitありますので、物理的には0000~1111(2進数)、すなわち0~15(10進数)の16個の数字が入ります。ですがBCDの桁が表すのは10進数の1桁、あくまでも入る数字は0~9です。ですので、左シフトをした後に10以上の数字が入っていたら繰り上げ処理をしてあげれば、問題なく「2倍」の数が得られるはずです。

というわけで実際にやってみましょう。
先ほどの流れで順調に左シフトをしていくと、5回目の左シフトで異変が起こります。
BCD1の位の値が「1111(2進数)」すなわち「15(10進数)」になってしまうのです。ここで桁あふれ処理として、1つ上の位に1を足して、自身は1の位の値だけを保持するという処理をしてあげます。そのシフト繰り上げ処理をしたのが以の図の下2行になります。

「左シフトを8回する過程でこんな変な処理を入れちゃっておかしくならないの?」と思う方もいるかもしれません。
しかし思い出してください。やっていることは「2進数の桁を1桁ずつ加算しては2倍するの処理を繰り返す」ということだけです。その過程で、物事の見方が2進数からBCDに切り替えているのならば、ちゃんとBCDのルールに沿って2倍する処理をしてあげればいいだけです。BCDのルールでは、「各桁の値は10以上になってはいけない」がありますので、超えてしまったらちゃんと次の桁に送ってあげればいい、それだけです。「2進数の桁を1桁ずつ加算しては2倍するの処理を繰り返す」以上のことは何もしていません。ですので、BCDの表現ルールに従ったうえでの結果にはなりますが、数値の意味としては何ら変わりません。

この調子で続けていきます。毎回シフトするたびに各桁が桁あふれしていないか確認し、桁あふれしていれば繰り上げ処理をしてあげます。
 合計8回シフトし終わった時点で、2進数の部分の数値はすべて出払い、BCDの部分には左から順に「1」「2」「3」が格納されました。このようにして、除算を一切使わずにBCD変換ができてしまうのです。

落とし穴

さて、ここまで説明してきた方法には落とし穴というか、考慮されていないパターンがありました。気づいた方はいますでしょうか。これで気づけていたらすごいと思います。

例えば「152(10進数)」=「10011000(2進数)」のBCD変換を考えてみましょう。
4回シフトした時点でBCD1の位が1001(2進数)=9(10進数)となるので、桁あふれはしていません。ですので、このまま進めて左シフトをします。
やっていることは「値を2倍して次の桁を足す」の処理なので、BCD1の位は9を2倍して18、その1はBCD10の位へ繰り上がり、残りの8に2進数のエリアからやってきた次の桁「1」が足し合わされ、9になることが期待されます。 しかし実際に入っていたのは3でした。
このまま続けていくと、最終的にBCDエリアには0001 0000 0100の値が入り、結果は「104」となってしまいます。157とは違う、間違った計算結果になってしまいました。

何が間違っていたかというと、9をそのまま左シフトしてしまったことです。
9を2倍すると桁あふれすることはわかり切っていますが、その後の結果は 「3」となっており、桁あふれを検知できません。すなわち、「BCDの桁あふれ」の章で赤字で書いた「左シフトをした後に10以上の数字が入っていたら」というのは実は間違い(条件不十分)だったわけです。桁あふれを起こしても10以上の数字が入らないことがあるんですね。

先ほども書いたように、9を2倍するということは、1つ上の桁に1が入り、自身の桁には8が入ることを意味します。しかし、単純に左シフトをしてしまうと、1つ上の位には1が入りますが、自身の桁には2が入ります。
それもそのはず、左シフトでは単なる16進数としての扱いなので、9の2倍である12(16進数)をそのまま10進数とみなしてBCD1の位に2が入ってしまっていたのです。

すなわち、従来の「左シフト(=2倍)して10以上ならば繰り上げ処理をする」という方法では、値が8, 9のときに10以上になったことを検知できなくなってしまいます。かなり厄介です。

対策

これを防ぐにはどうしたら良いでしょう。値が8, 9のとき、次回繰り上げ処理をするようなプログラムを書く?いやいや、前回の演算が影響してくるループとかややこしくてやってられません。バグの元です。

こんな時はまるっきり見方を変えてみましょう。

「桁の値が5以上ならば次に左シフトすると桁あふれを起こすから、あらかじめ繰り上げ処理をしておこう」

これならどうでしょうか。
繰り上げ処理とは、10~15の6つの数字をスキップすることですから、10以上だった場合は6を足してあげれば良いのです。6を足せば自身の位の最上位bitが自然とあふれて1つ上の桁の最下位bitに1が足されます。
これを左シフトする前に行うのならば、6の代わりに3を足しておけば、シフト後に6を足したのと同じ計算結果になるはずです。

というわけで、方針がここまでで以下のように推移してきました。

×「桁の値が10以上ならば桁あふれを起こしているから、1つ上の桁に1を足して自身から10を引こう」←桁あふれを起こしても10以上にならないことがあるからダメ
△「桁の値が10以上の場合の処理に加えて、左シフト前の桁の値が8,9のときは左シフト後に桁の値が10以上にならない桁あふれを起こすから、その場合の処理を記述しよう」←正しいが実装上の条件が複雑でよくない
○「桁の値が5以上ならば次に左シフトすると桁あふれを起こすから、シフト完了後に6を足そう」←だいぶ条件がスッキリしたが、ループの前回の条件が影響するのはまだ改善の余地あり
◎「桁の値が 5以上ならば次に左シフトすると桁あふれを起こすから、あらかじめ3を足しておこう」


これに従って先ほどのBCD変換をしてみます。
 先ほどは「1001だから桁あふれしていない」、シフト後は「0011だから桁あふれしていない」という判定となり、本当は桁あふれしていることに気づかずに通り過ぎてしまっていましたが、今回は「1001は5以上だから次のシフトで桁あふれする」という判断になり3を足すことができましたので、次のシフトでBCD1の位に正しく「1001(2進数)」=「9(10進数)」が入りました。

続きを見ていきましょう。
左シフトの前には必ず各桁に対して「5以上か」という判定が入り、5以上ならば3を足すことで繰り上げ処理としています。
これで、最終的に8回シフトが終わった時点で0001 0101 0010で152が得られました。めでたしめでたし。

まとめ

かなり丁寧に書きましたが、まとめるとアルゴリズムの考え方としては以下の通りです。
  1. 2進数の各桁(=bit)を2倍しては次の桁を足して…という処理をBCD上で行うことで2進数→BCD変換を行う。
  2. 2倍する際にはBCDだと桁あふれが起こるため、分岐処理が必要。
  3. 桁あふれしてから繰り上げ処理をしようとすると条件が複雑になるので、2倍する前に桁の値が5以上だったら3を足して繰り上げ処理とする。
アルゴリズムとしてはここまでで、これをいかに効率よく処理させるかということに関しては以前の記事にしっかり載っていますので、必要な方は参照してください。