вівторок, 9 квітня 2013 р.

Аналіз DATE.COM з PC-DOS 1.00

Колупався я недавно в антикваріаті ("MS/PC DOS 1.0", "MS/PC DOS 1.XX в емуляторах", "MS/PC DOS 1.XX "Ось ти який, північний олень!"", "DOS FCB"). Взагалі, цікаве заняття. :-) В процесі натрапив на людину, яка дизасемблювала та прокоментувала вихідні тексти IBMBIO.COM з PC DOS 1.00: "Reverse-Engineering DOS 1.0 – Part 2: IBMBIO.COM" (як і його бутлоадер: "Reverse-Engineering DOS 1.0 – Part 1: The Boot Sector"). Крім того, дизасемблювання  бутсекторів виявилося потужним засобом, щоб розібратися, чому в емуляторах не вантажаться різні версії ранніх DOS -- довелося і самому трішки спробувати.

Завершивши той цикл постів, на якийсь час заспокоїв свій археологічний свербіж. Виявилося -- не на довго. Повернення відбулося, коли, чистячи відкриті тематичні вклади, майже випадково натрапив на сайт якогось пана (пані?, але жінки, зазвичай, такими дурницями не страждають), що викладав образи своїх старих дискет: "Data Pack Rat". Дуже багато безцінного історичного матеріалу -- старовинні компілятори, оболонки, утиліти і т.д. Толком ще не розгрібав, натраплю на ще що цікаве -- розповім. Але знайомство почалося із майже чуда! У нього знайшлося кілька дискет, 1987-го року, на яких якийсь псих (в хорошому розумінні цього слова!) розповсюджував дизасембльований DOS 1.10 та DOS 2.10. Підписані вони так: 

Don Jindra
Information Modes
P.O. Drawer F.
Denton, Texas  76202
817-387-3339


Знайти якусь додаткову інформацію про цю людину-компанію, крім однієї згадки десь в Usenet не вдалося. Скачати оригінальні образи можна тут. Вони в хитрому форматі, щоб дістати із них файли, довелося витратити пару вечорів, хоча, в результаті, нічого аж такого складного в тому не було. Щоб спростити собі та читачам життя, ось перепаковані версії:  PC_DOS_1.10_disasm.ZIP, PC_DOS_2.10_disasm.ZIP. Також, асемблерний файл із IBMBIO, взятий за посиланням вище (в тому пості текст негарний, зіпсутий загадковими, розкиданими по тексту, символами): IBMBIO100.ASM. Файл компілюється NASM, результат ідентичний оригінальному IBMBIO, за винятком зміни кількох байт (сучасні асемблери іноді використовують трішки інші, альтернативні, форми кодування команд).

Детально вміст цих дискет поки не аналізував. Проглянув, порівняв із згаданим вище дизасембльованим IBMBIO. Звірив із тим, що відомо з пізнішої чи малодоступної тоді документації, трішки заліз дизасемблером сам. (В декількох місцях автор чесно зізнавався, що не розуміє певні місця, відомі мені по більш сучасних аналізах.) Виглядає, що робота виконана доволі акуратно та систематично.

Стало цікаво подивитися на еволюцію ранніх DOS, тим більше із двома такими ґрунтовними підказками. Однак, дизасемблювати весь DOS -- довго. Вирішив почати з перших версій різноманітні дрібних утиліт. Для дизасемблювання використовую IDA 5.0 Free. Почав із найменшої програмки -- страшно ж, DATE.COM. Потім усвідомив, що вибір не найкращий -- в наступних версіях її немає. Але ж не викидати. Так що поділюся. :-)

Перш ніж перейти безпосередньо до коду, декілька зауважень:
  1. Дизасемблювання, декомпіляція, і т.д., не зважаючи на активні спроби компаній заборонити його в своїх ліцензіях, в більшості випадків є явно дозволене законом. (На цей випадок у ліцензіях робиться явний виняток -- "обмежуємо в мірі, дозволеній законом". А куди їм дітися? ;-). Це, звичайно, не стосується подальшого використання отриманого так коду. Див. також "Clean room design".
  2. На відміну від права дизасемблювати копію, якою ви володієте, DOS 1.XX, формально він все ще захищені копірайтом. Хоча, навряд чи Microsoft судитиметься за їх незаконне розповсюдження. Крім того, більшість утиліт ранніх версій не містять якого-небудь "копірайта".
  3. IDA -- надзвичайно потужний, але нетривіальний у використанні інструмент. Довелося витратити певний час, освоюючи його засоби. Зокрема, у нього дуже розвинута система засобів підказати дизасемблеру, що тут насправді відбувається. Однак, система ця -- достатньо безбашенна, зіпсувати попередні наробітки може тільки так. Детально про роботу з IDA поки не писатиму. Якщо цікаво -- пишіть, щось придумаємо.
  4. Де взяти образи ранніх DOS, і як з них виколупати файли (наприклад DATE.COM), описано тут: "MS/PC DOS 1.XX в емуляторах ".
  5. Коментарі в коді пишу англійською (ну, як знаю, так пишу -- помилок багато), щоб могли прочитати й іноземні колеги. Текст ніби простий, будь-який грамотний комп'ютерщик мав би його розуміти без проблем, навіть якщо толком не знає англійської. Але якщо є труднощі -- пишіть, перекладемо "назад".
Отож, DATE.COM -- звичайна COM програма, яка побайтово вантажиться у пам'ять, починаючи із зміщення 100h від початку сегменту (вище неї, в цих 0FFh байт, знаходиться PSP, ну і не забуваємо про умовність поняття сегменту в реальному режимі x86).
COM файл, коли йому передається керування, отримує (на додачу до інформації в PSP), наступні дані:
  • CS=DS=ES=SS,всі вказують на той же сегмент,
  • SP=0FFFEh -- стек у кінці сегмента, росте вниз,
  • IP=0100h -- починаємо зразу після PSP.
  • AL = 00h, якщо перший FCB в PSP має правильну літеру диску, 0FFh, якщо ні.
  • AH -- аналогічно для другого FCB з PSP.

Код, сумісний з NASM:

; This file is generated by The Interactive Disassembler (IDA)     
;  Copyright (c) 2010 by Hex-Rays SA, <support@hex-rays.com>     
;  Licensed to: Freeware version       
;  
; Modified to compile by nasm and commented by Indrekis, indrekis2.blogspot.com
;
;  Input MD5 : 432D0670686C7B1FE428063843855D3F
;  File Name  : <>\DOS1\images\IBM PC-DOS 1.0 (5.25)\files\DATE.COM
;  Format      : MS-DOS COM-file
;  Base Address : 0h Range: 100h-1FCh Loaded length: FCh

; stack_top is set to allow for variables, which are located above programm code.
; This "self-willed" memory management was widely used by early DOS commands
; By the way, there were no memory management means in DOS 1.XX 
; DATE.COM do not use any such variables, though. 
 stack_top EQU  288h   ; New Stack top

  CPU 8086
  use16
  org 100h

start: ; 
  mov  sp, stack_top ; Set stack just 8D bytes after the programm code. Rather small.
  mov  dx, aCurrentDateIs ; "Current date is $"
  mov  ah, 9
  int  21h  ; DOS - PRINT STRING
      ; DS:DX -> string terminated by "$"
  mov  ah, 2Ah
  int  21h  ; DOS - GET CURRENT DATE
      ; Return: DL = day, DH = month, CX = year
      ; AL = day of the week (0=Sunday, 1=Monday, etc.)
  mov  al, dh  ; Ignores the day of weak, month to AL
  call  PrnDecFromAL
  mov  al, 2Dh  ; Char '-'
  call  PrnASCIIFromAL ; AL -- symbol to print
  mov  al, dl  ; Current date to AL
  call  PrnDecFromAL
  mov  al, 2Dh  ; '-'
  call  PrnASCIIFromAL ; AL -- symbol to print
  sub  cx, 1900
  cmp  cx, 100
  jb  short less_then_2000y
  sub  cx, 100  ; If 2000 or later -- print only last two digits

less_then_2000y:   
  mov  al, cl
  call  PrnDecFromAL ; Print two last digits of current year

enter_date_l:   
  mov  dx, aEnterNewDate ; To this point we have printed previous date,
      ; so asking for new.
  mov  ah, 9
  int  21h  ; DOS - PRINT STRING
      ; DS:DX -> string terminated by "$"
  mov  ah, 0Ah
  mov  dx, KbdBuffer
  int  21h  ; Reading from keyboard.
      ; DOS - BUFFERED KEYBOARD INPUT
      ; DS:DX -> buffer
      ; Buffer format (http://www.ctyme.com/intr/rb-2563.htm)
      ;  Size   Description   (Table 01344)
      ; 00h  BYTE  maximum characters buffer can hold
      ; 01h  BYTE  (call) number of chars from last input which may be recalled
      ; (ret) number of characters actually read, excluding CR
      ; 02h  N BYTEs  actual characters read, including the final carriage return
  mov  si, KbdBuffer+2
  cmp  byte [si], 0Dh ; If entered only "Enter" -- exiting
  jz  short exit_prog
  call  TwoCharsToNumber ; SI points to buffer with chars, AH -- result
      ; Adjusts SI to point to next char in buffer
  mov  dh, ah  ; In DH should be the month
  call  CheckSeparator ; SI -- points to buffer, correct separators: '/','-'
      ; If there is no correct separator, exits program.
      ; Adjusts SI to point to next char in buffer.
  call  TwoCharsToNumber ; SI points to buffer with chars, AH -- result
      ; Adjusts SI to point to next char in buffer
  mov  dl, ah  ; In DL should be the date
  call  CheckSeparator ; SI -- points to buffer, correct separators: '/','-'
      ; If there is no correct separator, exits program.
      ; Adjusts SI to point to next char in buffer.
  call  TwoCharsToNumber ; SI points to buffer with chars, AH -- result
      ; Adjusts SI to point to next char in buffer
  mov  cx, 1900
  cmp  byte [si], 0Dh ; Check if year has two digits or more
  jz  short YearHas2Chars
  mov  al, 100  ; If more than 2 -- expecting 4-digits year
  mul  ah  ; First two digits multiply by 100d
  mov  cx, ax  ; Setup CX correspondingly
  call  TwoCharsToNumber ; SI points to buffer with chars, AH -- result
      ; Adjusts SI to point to next char in buffer

YearHas2Chars:    
  mov  al, ah  ; Two last digits of year -- from AH to AL
  mov  ah, 0
  add  cx, ax  ; Calculate year
  mov  ah, 2Bh
  int  21h  ; DOS - SET CURRENT DATE
      ; DL = day, DH = month, CX = year
      ; Return: AL = 00h if no error /= FFh if bad value sent to routine
  or  al, al  ; Fasm uses 0A (r8, r/m8) instead of original 08 (r/m8, r8)
  jnz  short InvalidDateEnterd

exit_prog:   
  int  20h  ; DOS - PROGRAM TERMINATION
; start  endp    ; returns to DOS--identical to INT 21/AH=00h


; ============== subroutine ===========================================

; SI -- points to buffer, correct separators: '/','-'
; If there is no correct separator, exits program.
; Adjusts SI to point to next char in buffer.

CheckSeparator:   ; proc near  
  lodsb 
  cmp  al, 2Fh  ; Char '/'
  jz  short exit_inputting
  cmp  al, 2Dh  ; Char '-'
  jz  short exit_inputting
 
InvalidDateEnterd:    
  mov  dx, InvalidDate ; "\r\nInvalid date$"
  mov  ah, 9
  int  21h  ; DOS - PRINT STRING
      ; DS:DX -> string terminated by "$"
  mov  sp, 288h ; Clear the stack
  jmp  near enter_date_l ; Try once more
      ; (jmp is near, not short! -- original assembler pecularity)
;CheckSeparator endp    

; ============== subroutine ===========================================

; SI points to buffer with chars, AH -- result
; Adjusts SI to point to next char in buffer

TwoCharsToNumber:   ;proc near  
  call  CharToDigit ; Converts char to corresponding digit
      ; SI -- pointer to char, AL -- result.
      ; Adjusts SI to next char
      ; If conversion is impossible -- sets CF=1 and exits,
      ; using retn of function above.
      ; (It is strange, but correct -- return adress is in stack)
  jb  short InvalidDateEnterd
  mov  ah, al
  call  CharToDigit ; Converts char to corresponding digit
      ; SI -- pointer to char, AL -- result.
      ; Adjusts SI to next char
      ; If conversion is impossible -- sets CF=1 and exits,
      ; using retn of function above.
      ; (It is strange, but correct -- return adress is in stack)
  jb  short exit_inputting
  aad    ; AL := AH * 10 + AL;
      ; AH := 0;
  mov  ah, al  
 
exit_inputting:           
  retn
;TwoCharsToNumber endp


; ============== subroutine ===========================================

; Converts char to corresponding digit
; SI -- pointer to char, AL -- result.
; Adjusts SI to next char
; If conversion is impossible -- sets CF=1 and exits,
; using retn of function above.
; (It is strange, but correct -- return adress is in stack)

CharToDigit:   ; proc near  
     
  mov al, [si] ; SI points somewhere in input buffer
  sub al, 30h  ; Char to number, if '0'<=al<='9'
  jb short exit_inputting ; Exit, if less than 0, CF=1
  cmp al, 0Ah
  cmc   ; reverses the setting of the carry flag.
  jb short exit_inputting ; Exit if large than 10, CF=1
  inc si  ; Proceed to next char
  retn
;CharToDigit endp


; ============== subroutine ===========================================

PrnDecFromAL:  ; proc near  
  aam   ; ASCII adjust AX after multiply
     ; AH := AL / 10;
     ; AL := AL MOD 10;
     ; Result:
     ;  AL -- lower digit
     ;  AH -- higher digit
  xchg al, ah  ; Printing higher digit
  or ax, 3030h ; '0'=30h, '1'='0'+1 and so on --
     ; converting digits to their ASCII codes
     ;
  call PrnASCIIFromAL ; AL -- symbol to print
  mov al, ah  ; Now prepare lower digit and fall through
     ; to char printing subroutine
;PrnDecFromAL endp   

; ============== subroutine ===========================================

; AL -- symbol to print

PrnASCIIFromAL:   ;proc near  
  xchg ax, dx
  push ax
  mov ah, 2
  int 21h  ; DOS - DISPLAY OUTPUT
     ; DL = character to send to standard output
  pop ax
  xchg ax, dx
  retn
;PrnASCIIFromAL endp

; ============== data ===========================================
InvalidDate db 0Dh,0Ah  ; 
  db 'Invalid date$'
aCurrentDateIs db 'Current date is $'  ; 
aEnterNewDate db 0Dh,0Ah  ; 
  db 'Enter new date: $'
KbdBuffer db  0Ch   ; 
     ; Format:
     ; 00h  BYTE  maximum characters buffer can hold
     ; 01h  BYTE
     ;      (call) number of chars from last input which may be recalled
     ;      (ret) number of characters actually read, excluding CR
     ; 02h  N BYTEs  actual characters read, including the final carriage return
     ; (http://www.ctyme.com/intr/rb-2563.htm)
  db    0
unk_1FB  db    0

Скачати його, разом із лістингом, згенерованим IDA, скомпільованим файлом та, для порівняння, оригінальним DATE.COM, можна тут.

Проаналізуємо цей код.

Рекомпіляція

Код, згенерований IDA, довелося трохи підправити напильником, щоб він скомпілювався NASM: останній не знає нічого про директиви сегментів, процедур, не потребує і не знає offset та ptr, ну і ще деякі дрібниці. Асемблюється тривіально:
"nasm date_my_2.asm -o date_my_2.com"

Результат трішки відрізнятиметься від оригінального (до речі, згенерований MASM співпадатиме із тим, що дає NASM):

Проаналізуємо відмінності.

Перша ж багато що прояснить: в оригінальному файлі 8A C6, у нас 88 F0. Оригінальний опкод відповідає "mov al, dh". Що ж таке 88 F0? Тут підказують: "88 /r" -- "Move byte register to r/m byte", "8A /r" -- "Move r/m byte to byte register". Тобто, команди повністю еквівалентні, просто x86 має окремі коди для "перемістити байтовий регістр або пам'ять у регістр" і "перемістити байтовий регістр у пам'ять або регістр", тотожні по суті, якщо обидва операнди -- регістри. Оригінальний асемблер  явно більше любив 8A, сучасні -- 88. Аналогічно, 8B i 89, mov для 16-бітних регістрів, 03 і 01 -- add для 16-бітних, 0A i 08, or для 8-бітних.

Ще одна різниця: за зміщенням 5A, 82 в оригінальному, 80 -- сучасний. Так виглядає, що опкоди повністю еквівалентні (не бачу навіть, в чому ж між ними різниця, невже "r/m8, imm8", проти "imm8, r/m8"?).

Решта коду повністю тотожні.

Алгоритм роботи

  • Спершу програма встановлює стек на 288h -- всього 0FCh байт над місцем, де завершується код. Хоча, цього повинно вистачити. DATE.COM тим не користується, але судячи з аналізу інших, ранні утиліти вважали, що вся пам'ять вище від них -- їхня, принаймні до кінця сегменту. Тому просто переставляли стек нижче, і розташовували вище від нового стеку свої дані. А що було робити -- засобів керування пам'яттю (виділення, звільнення і все таке), ще не було.
  • Далі виводиться поточна дата. Програма знає про дати, більші за 2000 рік, але зустрівши такі, все рівно виводить лише дві останніх цифри.
  • Наступним кроком просить ввести нову дату: "Enter new date: ", читає з клавіатури в буфер, за допомогою ф-ції 0Ah/int 21h, "BUFFERED INPUT". Максимум 12 символів.
  • Введена стрічка аналізується. Якщо введено порожню стрічку -- натиснуто Enter, зразу завершує роботу.
  • Якщо ні, перетворює перших два символи в число. Якщо не вдалося -- виводить "Invalid date", і пропонує спробувати ще. Вважається, що першим вводиться місяць.
  • Далі перевіряє наявність коректного сепаратора -- '/' або '-', якщо немає -- пропонує спробувати ще. 
  • Аналогічно, аналізує наступну пару символів (очікуючи цифри), плюс сепаратор. Це буде день місяця.
  • Перевіряє: під рік відводиться два символи, чи більше (але не перевіряє, чи не є їх менше двох, хоча це не проблема -- про невірне число повідомить підпрограма перетворення символів у число). Якщо більше, вважає, що введено всі чотири цифри року. Обчислює заданий рік.
  • Отримані місяць, дату і рік передає функції 2Bh/int 21h, "SET SYSTEM DATE".
  • Якщо дата була успішно встановлена -- виходить, якщо ж функція "SET SYSTEM DATE" повернула помилку -- знову перепитується.

"Документація"

З аналізу коду бачимо, що:
  • Ніяких опцій команда не приймає, просто їх ігнорує.
  • Порядок введення: два символи на місяць, сепаратор, два на день, сепаратор, два або чотири на рік. Приклад: MM-DD-YYYY.
  • Сепаратором може бути '/' або '-'.
  • Просто "Enter" завершує програму без зміни дати.
  • Якщо програма вирішує, що дата невірна -- перепитується ще раз.
Все -- утилітка справді проста. Правда, і її розмір -- 252 байти.

Трішки про код

Код, так би мовити, хитрий. Мало того, що жорстокий асемблер, так ще й ніякої поваги до структурного програмування. Підтримувати такий -- це жах. До речі, утиліти наступних версій DOS явно писалися з більшою повагою до структури.

Приклади (які оригінальні назви процедур були, само собою, невідомо, тому користуюся придуманими мною, тими, що використано в коді вище):
  • CharToDigit, для виходу по помилці, переходить до retn в іншій процедурі, TwoCharsToNumber, але якщо все ОК -- користується "власним". Дрібничка. 
  • CheckSeparator навпаки, виходить по retn з  TwoCharsToNumber, якщо все ОК, або переходить на мітку початку введення дати, (enter_date_l). Перед тим чистить стек від попередніх переходів -- встановлює його вершину у початкове значення. Вже цікавіше.
  • PrnDecFromAL друкує перший символ числа, для власне виводу на екран викликаючи PrnASCIIFromAL, а потім готує другий і просто провалюється в PrnASCIIFromAL. Чим не оптимізована хвостова рекурсія? :-) Ну і байт зекономили -- на retn. Правда, оригінальний асемблер якраз один байт "пролюбив", використавши near перехід там, де цілком пасував на байт коротший short-перехід. (Див. коментарі у коді).
Любуватися тим кодом, в певному сенсі, приємно. Але, заради всього святого, не пишіть самі так!!!

З DATE.COM все -- було б про що говорити, насправді. :-) Далі, вже "готові", але не причесані SYS.COM для 1.00 і 1.10, CHKDSK.COM для 1.0. Можна буде й еволюцію відслідкувати. А поки:
Дякую за увагу!

Довідкова література

Немає коментарів:

Дописати коментар