SerialPort.BaseStream.ReadAsync сбрасывает или скремблирует байты при чтении с USB-порта.

Изменить: я добавил код отправки и пример полученного вывода я получаю.


Я читаю данные с "виртуального" последовательного порта USB, подключенного к встроенной системе. Я написал два метода для получения данных, один синхронный и один асинхронный. Синхронный работает, а асинхронный теряет или скремблирует немного входящих данных. Я не могу сказать, почему второй отказывает.

Метод, который работает, вызывает SerialPort.Read с тайм-аутом чтения, установленным в ноль, и он запрашивает все в буфере приема. Я проверяю возвращаемое значение, чтобы увидеть, сколько байтов было фактически прочитано, а затем помещаю данные в кольцевой буфер для использования в другом месте. Этот метод вызывается прерыванием по таймеру, и он отлично работает для приема последовательных данных (обычно со скоростью выше 1,6 Мбит / с без потери данных). Однако таймер опроса стал для меня проблемой, и я предпочел бы получать данные асинхронно с остальной частью моего кода.

Метод, который теряет данные, ожидает ReadAsync на последовательном порту BaseStream и зацикливается, пока не будет отменен. Такой подход работает, но он часто возвращает старший байт пакета не по порядку, довольно часто теряет один байт (примерно раз в несколько тысяч байтов данных) и иногда теряет сотни последовательных байтов из пакета.

Возможно, что здесь есть две совершенно разные проблемы, потому что потеря больших кусков потери данных, кажется, коррелирует с более высокими скоростями передачи данных и более высокой активностью системы. Эта конкретная часть проблемы может быть связана с переполнением буфера - возможно, из-за сбоя USB-рукопожатия, когда планировщик USB обнаруживает задержку - но пример, который я показываю здесь, имеет только очень небольшое количество данных, передаваемых при 50 мс интервалы, и система простаивает, за исключением этой процедуры тестирования.

Я заметил, что ReadAsync часто возвращает первый байт пакета при одном чтении, а остаток пакета при следующем чтении. Я полагаю, что это ожидаемое поведение, потому что MSDN говорит, что если в течение некоторого периода времени нет данных, ReadAsync вернется с первым полученным байтом. Тем не менее, я думаю, что это поведение каким-то образом связано с моей проблемой, потому что, когда один байт отсутствует или выходит из строя, это "всегда" первый байт, а остальная часть пакета поступает нормально.

Когда пакеты маленькие, "пропущенный" байт с начала пакета часто (но не всегда), по-видимому, доставляется при следующем чтении после оставшейся части пакета, и это просто не имеет для меня никакого смысла. С большими пакетами это все еще иногда случается, но чаще всего первый байт просто отсутствует, когда пакеты большие.

Я искал повсюду, и прочитал каждый SO вопрос, который я мог найти по этой теме. Я нашел других людей с похожей проблемой (например, в SerialPort.BaseStream.ReadAsync, пропустившей первый байт), но ни у кого не было приемлемых или даже вероятных решений.

Бен Фойгт (Ben Voigt) ( http://www.sparxeng.com/blog/software/must-use-net-system-io-ports-serialport) и другие, которые, похоже, знают, что последовательные порты рекомендуют использовать ReadAsync в базовом потоке, и команда IOT Microsoft также рекомендовала этот подход, поэтому я должен верить, что этот подход должен работать.

Вопрос 1: Почему мой код использует ReadAsync на байтах сброса / скремблирования USB Serial BaseStream?

Вопрос 2: Если невозможно выполнить ReadAsync для надежного возврата всех байтов, полученных байтов, в правильном порядке, могу ли я просто поместить асинхронную оболочку вокруг традиционного SerialPort.Read и ожидать / зацикливать ее, чтобы мне не приходилось опрашивать из таймера? Я читал, что это плохая идея, но я также читал, что класс SerialPort является внутренне асинхронным, так что, возможно, это делает это нормально? Или моя единственная альтернатива - поместить это в рабочий поток и просто позволить ему тратить все время на ожидание?

Мой код ниже. Я поставил serialPort1.ReadTimeout = 0; а также serialPort1.BaseStream.ReadTimeout = 0; (и я пробовал другие продолжительности). Я включил RTS и DTR, и так как это порт USB_serial, он должен обрабатывать рукопожатие внутри, и, безусловно, он делает это, когда я читаю синхронно - но, возможно, это не так, когда я читаю из BaseStream?

Вот первый метод:

// this method works perfectly when called from a timer.
// SerialPort.ReadTimeout must be set to zero for this to work.
// It handles incoming bytes reliably at rates above 1.6 Mbps.

private void ReadSerialBytes()
{
    if (!serialPort1.IsOpen)
        return;

    if (serialPort1.BytesToRead > 0)
    {
        var receiveBuffer = new byte[serialPort1.ReadBufferSize];

        var numBytesRead = serialPort1.Read(receiveBuffer, 0, serialPort1.ReadBufferSize);
        var bytesReceived = new byte[numBytesRead];
        Array.Copy(receiveBuffer, bytesReceived, numBytesRead);

        // Here is where I audit the received data.
        // the NewSerialData event handler displays the 
        // data received (as hex bytes) and writes it to disk.
        RaiseEventNewSerialData(bytesReceived);

        // serialInBuffer is a "thread-safe" global circular byte buffer 
        // The data in serialInBuffer matches the data audited above.
        serialInBuffer.Enqueue(bytesReceived, 0, numBytesRead);
    }
}

Вот второй метод, отредактированный для удаления хвостовой рекурсии, отмеченной @Lucero. Теперь у меня не хватит памяти:), но первоначальная проблема потери данных, конечно же, остается.

// This method is called once after the serial port is opened,
// and it repeats until cancelled. 
// 
// This code "works" but periodically drops the first byte of a packet, 
// or returns that byte in the wrong order.
// It occasionally drops several hundred bytes in a row.
private async Task ReadSerialBytesAsync(CancellationToken ct)
{
    while((!ct.IsCancellationRequested) && (serialPort1.IsOpen))
    {
        try
        {
            serialPort1.BaseStream.ReadTimeout = 0;
            var bytesToRead = 1024;
            var receiveBuffer = new byte[bytesToRead];
            var numBytesRead = await serialPort1.BaseStream.ReadAsync(receiveBuffer, 0, bytesToRead, ct);

            var bytesReceived = new byte[numBytesRead];
            Array.Copy(receiveBuffer, bytesReceived, numBytesRead);

             // Here is where I audit the received data.
             // the NewSerialData event handler displays the 
             // data received (as hex bytes) and writes it to disk.
             RaiseEventNewSerialData(bytesReceived);

            // serialInBuffer is a "thread-safe" global circular byte buffer 
            // The data in serialInBuffer matches the data audited above.
            serialInBuffer.Enqueue(receiveBuffer, 0, numBytesRead);
        }
        catch (Exception ex)
        {
            MessageBox.Show("Error in ReadSerialBytesAsync: " + ex.ToString());
            throw;
        }
    }
}

Вот код C++ из отправляющей системы (teensy 3.2 с чипом ARM). Он отправляет последовательность байтов от 00 до FF, повторяемую каждые 50 мсек.

 void SendTestData()
 {
    byte asyncTestBuffer[256] = { 0 };
    for (int i = 0; i < 256; i++)
        asyncTestBuffer[i] = i;

    while(true)
    {
    Serial.write(asyncTestBuffer, sizeof(asyncTestBuffer));
    delay(50);
    }
}

Традиционный синхронный SerialPort.Read (вызывается из таймера) получает каждый блок совершенно точно, как и ожидалось, без потери данных. Это выглядит так, снова и снова:

=====
32 msec => Received 256 bytes 
000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====

Теперь вот что получает SerialPort.BaseStream.ReadAsync. В другой версии я добавил порядковый номер терминального пакета, чтобы доказать, что, когда я вижу ноль, за которым следует еще один ноль, между ними на самом деле не весь отсутствующий пакет. Все порядковые номера пакетов присутствовали, поэтому ведущий байт действительно отсутствует или доставлен не по порядку.

7 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
5 msec => Received 1 bytes 
00
=====
55 msec => Received 1 bytes 
00
=====
4 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
42 msec => Received 1 bytes 
00
=====
5 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
68 msec => Received 1 bytes 
00
=====
7 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
31 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
9 msec => Received 1 bytes 
00
=====
33 msec => Received 1 bytes 
00
=====
10 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
55 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
12 msec => Received 1 bytes 
00
=====
12 msec => Received 1 bytes 
00
=====
15 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
68 msec => Received 255 bytes 
0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====
16 msec => Received 1 bytes 
00
=====
14 msec => Received 256 bytes 
000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF
=====

Я потратил пару недель на отслеживание этой проблемы, которая первоначально проявлялась в странном поведении продукта, находящегося в стадии разработки. Я почти уверен, что делаю что-то не так, но я просто не вижу этого, и в этот момент я совершенно отчаянно нуждаюсь в каких-либо мыслях или предложениях!

3 ответа

Решение

Я наконец-то придумал ответ после пошагового перехода по декомпилированному исходному коду для класса.Net SerialPort (только с установленным resharper Rclick on SerialPort->Navigate->Decompiled Sources).

Ответ #1: проблема с байтами вышла из строя из-за ошибки ранее в моей программе. Я отменил и перезапустил цикл readAsync, но я использовал неправильный токен отмены, поэтому было две копии цикла, ожидающих readAsync от последовательного порта. Оба выдавали прерывания, чтобы возвратить полученные данные, но, конечно, это было условие гонки, чтобы получить туда первым.

Ответ № 2: Обратите внимание на то, как я использую метод синхронного чтения: я не использую событие Received (которое не работает правильно) или проверяю количество байтов для чтения (что ненадежно) или что-то подобное. Я просто устанавливаю время ожидания равным нулю, пытаюсь читать с большим буфером и проверяю, сколько байтов я получил.

При таком вызове синхронный SerialPort.Read сначала пытается выполнить запрос на чтение из внутреннего кэша [1024] полученных байтов данных. Если у него все еще недостаточно данных для удовлетворения запроса, он выдает запрос ReadAsync для базового BaseStream, используя точно такой же буфер, (скорректированное) смещение и (скорректированное) количество.

Итог: при использовании так, как я его использую, синхронный метод SerialPort.Read ведет себя точно так же, как SerialPort.ReadAsync. Я пришел к выводу, что было бы неплохо поместить асинхронную оболочку вокруг синхронного метода и просто ждать ее. Тем не менее, мне не нужно делать это сейчас, когда я могу читать из базового потока надежно.

Обновление: теперь я надежно получаю более 3 Мбит / с от моего последовательного порта, используя задачу, содержащую цикл, который непрерывно ожидает SerialPort.Basestream.ReadAsync и добавляет результаты в кольцевой буфер.

Я знаю, что прошло довольно много времени с тех пор, как вопрос был задан / решен, но заметил это во время поиска. Раньше у меня были такие же "проблемы". В настоящее время я использую Pipereader поверх BaseStream последовательного порта для обработки чтения. Это позволяет мне очищать входящие буферы только тогда, когда у меня есть полное сообщение (и я получаю несколько сообщений одновременно). И, кажется, работает очень хорошо.

Код выглядит примерно так:

        var reader = PipeReader.Create(serial.BaseStream);
        while (!token.IsCancellationRequested)
        {
            ReadResult result = await reader.ReadAsync(token);

            // find and handle packets
            // Normally wrapped in a handle-method and a while to allow processing of several packets at once 
            // while(HandleIncoming(result))
            // {
                    result.Buffer.Slice(10); // Moves Buffer.Start to position 10, which we use later to advance the reader
            // }

            // Tell the PipeReader how much of the buffer we have consumed. This will "free" that part of the buffer
            reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);

            // Stop reading if there's no more data coming
            if (result.IsCompleted)
            {
                break;
            }
        }

См. Документацию по конвейерам здесь: https://docs.microsoft.com/en-us/dotnet/standard/io/pipelines

Я могу подтвердить, что зашифрованная последовательность все еще сохраняется (или вернулась?) в NET 6.

Я пишу свое первое настольное приложение NET 6 в январе 2022 года и впервые столкнулся с этой проблемой. Я использую класс SerialPort не менее 4 или 5 лет и никогда не сталкивался с этой проблемой. Я использую его почти в каждом приложении, которое использую для связи с различными устройствами.

Я только узнаю, что эта проблема существовала долгое время!. Самый старый отчет, который я видел, был датирован 2012 годом... и он все еще существует? Серьезно?

До сих пор приложения для последовательного порта, которые я писал, основаны на NET Framework 4.7.2 и более ранних версиях. В этой структуре SerialPort был частью System.dll. В NET 6 SerialPort — это расширение платформы, перемещенное в System.IO.Ports.dll, которое необходимо установить как пакет-самородок. Возможно ли, что они портировали старую версию с ошибками?

В моем тесте у меня есть старое приложение NET Framework 4.7.2, отправляющее строку каждые 20 мс через физический порт COM3 (без USB-адаптера). Приложение NET 6, считывающее строки, находится на том же рабочем столе и прослушивает второй физический порт (COM4). Оба порта соединены коротким модемным кабелем NULL, подключены только TX, RX, GND. Нет рукопожатия. Это зашифрованный результат, который, насколько я могу судить, носит случайный характер:

      <-- port open with tranmission running already -->
 fox jumps over the lazy Dog - 123456789]
[The quick brown fox jumps over the lazy Dog - 123456789]
[The quick brown[The quick brown fox jumps over the lazy Dog - 123456789]
[The quick brown fox jumps over the lazy Dog - 123456789]
[The quick brown fox jumps over the lazy Dog - 123456789]
<--- many good lines removed for brevity --->
[The quick brown fox jumps over the lazy Dog - 123456789]
[The quick brown fox jumps over he lazy Dog - 1t23456789]
[The quick brown fox jumps over the lazy Dog - 123456789]
<--- many good lines removed for brevity --->
[The quick brown fox jumps over the lazy Dog - 123456789]
[The quick brownfox jumps over  the lazy Dog - 123456789]
[The quick brown fox jumps over the lazy Dog - 123456789]

Обратите внимание, что в третьей строке есть первые три слова из первой строки!. После этого только один байт не на месте. Похоже на неряшливую реализацию двойного буфера...

упс! Я забыл эти байты! Вот, пожалуйста!

Если я открою второй старый добрый терминал (Net Framework 4.7.2) и прослушаю тот же поток, вывод будет идеальным.

В проекте NET 6 я использую точно такой же класс, который я написал давным-давно, чтобы инкапсулировать функциональность SerialPort, чтобы использовать ее от проекта к следующему.

До сих пор я подписывался на событие SerialPort.DataReceived, а затем в обработчике событий чтение происходит так внутри задачи (запущенной, но не ожидаемой обработчиком событий):

      var bytesToRead = _serialPort.BytesToRead;
byte[] Data = new Byte[bytesToRead];
int received = _serialPort.Read(Data, 0, bytesToRead);
... notify the class user.

Я проверю работу, предложенную здесь...

Другие вопросы по тегам