This is a tutorial and reference on how to use assembly code in QuickBASIC programs, by creating a library. I will focus on NASM, but the coding techniques will work with any assembler. This is not a tutorial on assembly language, however.
The primary source for this tutorial was a forum post by user "Artelius".
Create a new text file with the ASM extension and open it. Copy the following template into it:
GLOBAL MyProcedure GROUP DGROUP SECTION CODE MyProcedure: ; Code goes here
Now let's analyze it. At the beginning, you specify which procedures (labels really) you want to be accessible to QuickBASIC programs with GLOBAL. Each procedure needs to be on its own line with its own GLOBAL directive. Then
GROUP DGROUP tells the linker that your code will access variables which reside in QuickBASIC's default segment, which is called DGROUP (but you can still access data in other segments; see below).
SECTION CODE marks the beginning of code. After that, you can begin writing the procedures.
All procedures (both SUBs and FUNCTIONs) must have the following form (after the label):
push bp mov bp, sp ; Procedure body goes here pop bp retf x
All instructions except RETF are actually optional, but if you want to access parameters that QuickBASIC pushes to you (or modify them if you are being passed pointers), you will need them. Replace the x after RETF with the number of parameters you are being passed, times two (since they're 16 bits wide). For more information on FUNCTIONs and parameters, see Advanced techniques below.
Your code can now be assembled and linked. Create a batch file with the following contents:
@DEL %1.LIB N:\NASM -o %1.OBJ -f obj %1.ASM T:\LINK /Q %1.OBJ, %1.QLB,,T:\BQLB45.LIB; T:\LIB %1.LIB +%1.OBJ
Replace N:\ and T:\ with the locations of NASM and QuickBASIC respectively, or use the SUBST (DOS/Windows) or MOUNT (DOSBox) command to create virtual drives pointing to them. Now, whenever you want to make a NASM library, just type
ASMLINK library and you'll get a QuickBASIC library ready to be used.
Before QuickBASIC can use any of the procedures in your library, they have to be declared. It's good to create a BI file to contain the declarations. The declarations are written the exact same way as they are for BASIC procedures (with the DECLARE statement), however you may wish to pass certain parameters as BYVAL if you will not be modifying them. After you've written and $INCLUDEd your BI file, you can use your procedures.
This is more of a reference section for things that weren't explained in the steps above.
You can generally use any register.
If your procedure is a FUNCTION, AX and DX are used for returning values (see below).
QuickBASIC expects SI, DI, SP, BP, DS (I think), SS, and CS (obviously) to be preserved. You can PUSH and POP these registers, but sometimes (for example when calling a BIOS routine) you'll want to preserve SP because it will get destroyed. You can do a
MOV ES, SP as the equivalent of PUSH and
MOV SP, ES as the equivalent of POP (actually, any 16-bit register will work, but I use ES the least). I don't think QuickBASIC expects ES to be preserved; this technique has worked for me so far.
The starting values of the registers are undefined.
To return a value, your BI file needs to DECLARE the procedure as a FUNCTION. Then:
Here's an example of a FUNCTION that returns an INTEGER:
GLOBAL GetDefaultDrive GROUP DGROUP SECTION CODE GetDefaultDrive: ; An example of getting the current default drive. Declare as: ; DECLARE FUNCTION GetDefaultDrive% () mov ah, 19h ; DOS function int 21h mov ah, 0 ; Discard the higher byte add al, 65 ; Convert to ASCII retf ; Return to QuickBASIC (result is in AX)
QuickBASIC passes parameters to your procedure by pushing them onto the stack. You get them with BP-relative displacements. The last parameter DECLAREd has the lowest displacement, which is always 6 (because the two words below it store CS and IP as they were before your procedure was entered). For example, if you push a%, b%, and c%, then a% will have a displacement of +6, b% will have +8, and c% will have +10. The displacements are always even, but the size of parameters varies depending on their type and whether they were pushed by value or by reference. You can mix parameters passed by value and by reference; they don't all have to be the same. When your procedure is finished, make sure RETF has the correct operand (see Procedures above).
The easiest way to read parameters, especially INTEGERs, is to pass them by value. You do this by putting BYVAL before their name in the DECLARE statement in your BI file. Unfortunately, you cannot modify the parameters when they are passed by value, but you can read them directly by MOVing them into registers. Only INTEGERs, LONGs, SINGLEs, and DOUBLEs can be passed by value. Here's an example of getting an INTEGER by value:
GLOBAL PrintChar GROUP DGROUP SECTION CODE PrintChar: ; An example of getting a character BYVAL and printing it. Declare as: ; DECLARE SUB PrintChar (BYVAL CharCode%) ; Set up the stack push bp mov bp, sp mov ah, 2 ; DOS function (print character) mov dx, [bp+6] ; Character to print (high byte is ignored) int 21h ; Print it ; Return to QuickBASIC pop bp retf 2
By default, parameters are passed by reference so you can modify them. This also has the nice side effect that, since you get their offsets, they will each occupy 2 bytes on the stack. However, it requires some more work to get to them: first, you have to MOV the offset from the stack into a register, and only then you can use that register as a pointer. Here's an example of accessing an INTEGER by reference:
GLOBAL Increment GROUP DGROUP SECTION CODE Increment: ; An example of incrementing an INTEGER by reference. Declare as: ; DECLARE SUB Increment (What%) ; Set up the stack push bp mov bp, sp mov bx, [bp+6] ; Get pointer inc word [bx] ; Increment the variable ; Return to QuickBASIC pop bp retf 2
To get a far parameter by reference, pass its segment by value first (with the VARSEG function), then pass it by reference as usual. There's another way involving passing it with SEG (instead of putting BYVAL before the name, you put SEG, and you don't manually pass the segment), but I haven't experimented with that yet.
Note: this applies to QuickBASIC 4.5 and possibly other versions, but I haven't tested with them. It will not work with QuickBASIC 7.1 (and probably Visual Basic for DOS) because it uses a different way of storing strings.
Reading strings (I will not discuss modifying; see Calling QuickBASIC's internal procedures below), no matter whether they are dynamic or fixed-length (they temporarily get converted to dynamic strings), works the following way: QuickBASIC passes you the offset of the string descriptor. This is a 4-byte structure, which contains (in the following order) the length of the string, and a pointer (offset) to its contents. Both are 16 bits wide. You can also manually pass the string's length (get it with LEN) and offset (get it with SADD). You can then use an index register to point to the string and CX (or any other register) as a counter, then loop through the string. Here's an example of reading a "near" string:
GLOBAL StrPrint GROUP DGROUP SECTION CODE StrPrint: ; An example of printing a QuickBASIC string. Declare as: ; DECLARE SUB StrPrint (What$) ; Set up the stack push bp mov bp, sp push si ; SI has to be preserved mov si, [bp+6] ; Get pointer to string descriptor mov cx, [si] ; Get string length mov bx, [si+2] ; Get pointer to string contents mov ah, 2 ; DOS function (print character) .next: mov dl, [bx] ; Get a character int 21h ; Print it inc bx ; Move to next dec cx ; Decrease counter jnz .next ; Repeat if any characters left ; Return to QuickBASIC pop si pop bp retf 2
User-defined types are just a list of regular variables stored one after another. The exceptions are strings, which are always fixed-length and don't use string descriptors; they're just raw bytes, but without any way to get their length (you have to know it in advance). User-defined types are always "far"; see Accessing parameters that are "far" (not in DGROUP) above.
Arrays appear to be stored "far" and column-major (though I think the compiler can be forced to use row-major order). They are just raw lists of data. Dynamic string arrays are lists of string descriptors, and fixed-length string arrays are just one fixed-length string after another, without any length information (you must know the length in advance). To get the offset of the beginning of an array, use VARPTR with the first element in the array.
It is also possible to call routines that QuickBASIC itself uses to implement its statements and functions. I haven't tried it myself though. To use QuickBASIC's routines, declare them with the EXTERN directive between the
GROUP DGROUP and
SECTION CODE lines. For example, to declare B$SCAT (the routine used to concatenate strings), insert
EXTERN B$SCAT. If you want to research how QuickBASIC's routines work and what parameters they require, I suggest you compile some programs with the
/A parameter (on the command line; the compiler executable is called BC) to get an assembly listing of the compiled code.