Linux Kernel Hacking free course - 2003
Transcript of Lecture 08 by Alex Miocinovic
(Blue text are comments added by Alex)
Starting from this lesson the source file for our driver (als4000.c in previous lessons) will be divided into four parts (one header file and three .c files) :
als4000.h
als4000_main.c
als4000_dsp.c
als4000_hw.c
The main data structures are:
struct als4000_dma { } (defined in als4000.h)
struct als4000_descr { } (driver's peripheral descriptor defined in als4000.h)
struct pci_dev (defined in <linux/pci.h> ,
passed from kernel pci subsystem to driver in pci “probe” and “remove” calls )
struct pci_device_id als4000_idtable [ ] __devinitdata
(defined in <linux/pci.h> ,
set in als4000_main.c ,
passed from kernel pci subsystem to driver in pci “probe” call – als4000_probe ( ) )
struct pci_driver als4000_driver (defined in <linux/pci.h> ,
set in als4000_main.c )
struct file_operations als4000_dsp_fops (defined in <linux/fs.h >,
set in als4000_dsp.c )
als4000.h contains some data structure definitions ( see above ), some wrapper functions, and a number of parameter definitions (#define statements) that are strictly related to the particular PCI audio card we are using. These parameters shall be derived from the hardware manual.
als4000_main.c takes care of PCI functions defining the two most important calls needed by the kernel pci subsystem ( the “probe' call als4000_probe() and the “remove” call als4000_remove() ). It also defines the interrupt handler, some important pci subsystem data structures (see above) and the basic module “init” and “cleanup” functions
( module_init() and module_exit() ).
als4000_dsp.c set the fields of the “file_operations” data structure and defines the corresponding basic file operations relating to the DSP function on our audio card (open, close, read, write ). Also defines functions to initialize and deinitialize DMA.
Finally als4000_hw.c defines functions such as the audio hardware reset, and reading / writing the volume level of the mixer.
In the previous lessons we have seen how to implement the initialization functions needed by the PCI subsystem of the kernel. Those functions are now contained in the als4000_main.c file. In this lesson we will see how to initialize the device itself, implementing the als4000_dsp_open() and the als4000_dsp_release() calls.
Implementation of the als4000_dsp_open( ) system call
When a User Mode Process (UMP) invokes the "open" system call it uses the POSIX call open() that returns a file descriptor and, using a pseudo C language, can be described as
fd = open( pathname, flags, modes);
where "modes" refers to the access rights (i.e. read, write, execute, for owner, group and all) and is only applicable when "flags" indicates a "creation" type of open (i.e. an open call that refers to a file that doesn't exist yet, and must be created within the same open call).
The open() system call returns a file descriptor "fd" and it does the following :
- converts the "pathname" to an inode (struct inode in the inode table)
- allocates a file table entry that points to the inode and initializes the "count" and "offset" variables.
- allocates a user file descriptor entry (an entry in a private table in the process' uarea) and returns the index of this entry in the table as the "fd" to the user process. This "user file descriptor entry" points to the entry in the file table.
All this is handled within the VFS (Virtual File System), but then it calls the special "open" function that's declared in the "file_operations" data structure defined for that particular file type.
If the file is a regular file, the corresponding "special open" function is defined within the Linux VFS and depends on the type of file system to which the file belongs (ext2 for example).
If the file is a special (device) file the "special open" function is declared in the device driver (als400_dsp_open() in our case).
Both regular files and special files are accessed through the VFS kernel subsystem.
To implement the specialized "open" function for our DSP device file we first get the minor number of the device from the inode data structure passed as the first parameter to the call, define *p as a pointer to a driver's peripheral descriptor, and set to 0 the return value.
As we shall see only a max of 2 processes will be allowed to open our DSP device and only if one "open" is for writing into the device, while the other "open" is for reading from it (obviously the two processes may be one and the same process). Furthermore, at any point in time, there can only be one driver's peripheral descriptor allocated to our driver and concerning the operation of the DSP for each sound card that our driver controls.
We then check that the return value of
register_sound_dsp(&als4000_dsp_fops, -1);
is compatible with the value ( 3+16x i ) where "i" is the smallest integer that refers to a free minor number. The minor number returned by this function is stored in the i_rdev field of the inode structure and can be extracted using the macro MINOR( ).
This check is needed because we call the register_sound_dsp(); function with the -1 final argument, and this leaves to the kernel some freedom of choosing a particular minor number. Since we want to drive only /dev/dsp (not /dev/audio) types of devices, we better do a check.
Then we navigate our list of driver's peripheral descriptors (of which there can only be one for each sound card that we install in the PC, and that our driver supports) with
get_dsp_descriptor(minor);
Looking at the function's implementation (in file als4000_dsp.c) we see that the list navigation is protected with locks, since there may be more than one process using the driver and asking to open a different device (i.e. with a different minor number). Note that in function als4000_probe() we use the call to register_sound_dsp(); that returns the minor number we then use to set the dsp_dev element of the driver's peripheral descriptor that represents our sound card.
The argument &als4000_list_lock that we pass to both spin_lock(); and spin_unlock(); is declared at the end of als4000.h as a data structure of type spinlock_t and initialized in als4000_main.c .
The last argument "list" of the list_entry(); macro is a struct list_head element of the struct als4000_descr{ } declared in als4000.h . This macro is defined in <linux/list.h>.
In general spinlocks are only needed within an SMP system (multiple CPU running the same kernel). Spinlocks are not compiled when a kernel image is created for a uniprocessor system. In our case even for an SMP system the protection of the list of descriptors with spinlocks may not be necessary, since we are only navigating and reading the list. There is a need for spinlocks when we do insertion of a new descriptor in our list (this is done in the "probe" function).
If we get a non null descriptor from get_dsp_descriptor(minor); then we must control that only a process at a time is accessing the device file for reading it, but we know also that a second process could access the device concurrently, if it only wants to write on it.
Since our sound card support full duplex mode we can have zero, one or two processes accessing the device but if more than one process is accessing it, one must open it for reading, while the other must open it for writing. Obviously even the same single process could access concurrently the device if it's one for writing and one for reading , but there can never be a situation in which we open the device file twice for reading or twice for writing.
To control this we need another data structure and in particular in als4000.h we add, in our descriptor als4000_descr { } , an element representing a wait queue for processes that are waiting to access our device file ( wait_queue_head_t open_wait ).
We also need an element ( struct semaphore open_sem ) to control that there can't be two processes trying to concurrently open the same device file. This is a race condition that we want to avoid since both processes could gain access to the device and it will malfunction.
We then need an element ( int dsp_open_fmode) to register if the process opened the device file in RD or WR mode.
So in our als4000_dsp_open() we call down(&p->open_sem); to set our semaphore from 1 to 0. This semaphore is held only until completion of the als4000_dsp_open() call, which in fact ends with an up(&p->open_sem); . The semaphore does not need to protect the access to the device file, and in fact we need to be able to concurrently access it (in RD and WR mode), it only protects the als4000_dsp_open() and als4000_dsp_release() phases.
Within the down() and up() calls we are sure to be the only process in control and we start by saving the address of our driver's peripheral descriptor in a field of the "file" data structure ( file->private_data ). This “file” data structure is a kernel data structure representing an open file (either normal file or device file) and is passed by the VFS subsystem of the kernel to our als4000_dsp_open() function. In other words the "file" data structure represent within the VFS an interaction between a process and a file.
Since this same "file" data structure is passed by the VFS to the als4000_dsp_write() and als4000_dsp_read( ) functions, we then have a convenient way of passing the address of our descriptor als4000_descr { } to these functions.
We then check if our device file is being read from or written to by another process. What we obviously want to avoid is a conflict in the access mode to the device file. To do so we make a bit by bit AND between the way the device file has been previously opened
( p->dsp_open_fmode ), and the way the device file is being now opened
( file->f_mode). In these fields each bit has a meaning (on or off) for a corresponding mode (RD or WR or others) and initially all the bits in p->dsp_open_fmode are set to zero.
The only way of having a FALSE result in the
if (p->dsp_open_fmode & file->f_mode)
statement is when the mode the file is being opened differs from the mode it was opened before. But if we have a true result we must sleep, waiting for the other process to stop using the device file in the mode we are interested in. This would normally be quite easy to do but, having acquired a semaphore, we need to do it carefully as shown by all the instructions in the block of code of the previous if() statement (until just before the if(file->f_mode & FMODE_WRITE) statement).
So we start by calling DECLARE_WAITQUEUE(w, current); where current represent the process that is trying to open the device file, and w represent an element in a wait queue.
w is set to represent current in the wait queue.
Having defined w as current we then put this element in the wait queue we defined as belonging to our peripheral descriptor :
add_wait_queue(&p->open_wait, &w);
DECLARE_WAITQUEUE( ) is a macro that expands in the declaration and initialization of a wait-queue data structure. This macro is convenient because the resulting driver's code is more readable and portable. In fact we could avoid calling this macro if the process opening our device file did ask not to be blocked, so we could put this macro after the check on O_NONBLOCK and just before calling add_wait_queue(&p->open_wait, &w); .
Among the flags describing how to open a file there is also a non blocking flag
( O_NONBLOCK ) . This flag indicates that the process that is opening the device file should not be blocked if the open fails. A failure to open is clearly encountered when there is contention on the opening mode and so we must handle this situation before putting the process in a wait queue.
So we check file->f_flags against O_NONBLOCK and if the process that is opening the file requested a non-blocking open, we must stop the operation and return an error code indicating that completion of the operation would block the calling process
( -EWOULDBLOCK ).
If we return -EWOULDBLOCK , we must first reset our semaphore ( up(&p->open_sem); ), otherwise the device would not be openable anymore by any process.
If we add the process to the wait queue, we must stop the calling process until the device becomes available for the requested activity (RD or WR), and we must also reset the semaphore, since otherwise we would block the "open" and "release" functions" and there is no reason to do so. When the device becomes available again, we also need to acquire again the semaphore. Note that we must always be careful to release and acquire the semaphore in a way that avoids race conditions and useless blocking of driver's functions.
So, having added the process to our wait queue, we enter an endless for()loop and we start by declaring that the current process (the one that is trying to open the device) becomes "TASK_INTERRUPTIBLE". This means that our process enters a sleeping but interruptible state (i.e. it can receive signals). This doesn't yet means that we allocate the CPU to another process (this will come later), but only that the calling process changes state and in this state the process can't be chosen by the scheduler.
To avoid race conditions we must now (before we reset the semaphore) check again if any process had released the DSP device so that it's now openable for the type of action we are interested in. So we check again
if (!(p->dsp_open_fmode & file->f_mode))
and if the device had become available in the last few micro seconds
(i.e."p->dsp_open_fmode & file->f_mode" now results in a zero logic value (FALSE), due to differences between the way the device is being opened compared to the way it was opened before by another process), then the if argument evaluates to TRUE, we exit the for(;;) loop, and as we shall see, we will remove the process from our wait queue and set the process to a TASK_RUNNING state.
Otherwise we reset the semaphore, freeing the "open" and "release" operations and we invoke the scheduler so that it could choose another process to run.
As we will see, when a process closes our DSP it will awake all the sleeping processes in the wait queue ( wait_queue_head_t open_wait ) and, for the particular process that the scheduler chooses, the execution of our driver will resume as a return from the call to the scheduler. Remember that our driver and in particular the "open" call for the DSP device is always run within the context of a user space process.
Returning from the schedule(); call, we acquire again the semaphore and resume the for( ) endless loop since we want to control that we can really proceed with execution.
Note that our DSP device could have been opened once for reading and once for writing, but if the device gets closed by the reading process and we (as a process) would like to write on the DSP, we still will be blocked. So even if we are awaken, we still must control once again if the DSP can be really opened in the way we (as a process) are interested in.
So reentering the for()loop, and having reacquired the semaphore, we do again
set_current_state( TASK_INTERRUPTIBLE );
and check again
if (!(p->dsp_open_fmode & file->f_mode))
Note that a process in "TASK_INTERRUPTIBLE" state can be also awakened by a signal (as the one that is generated when the user press Ctrl-c. So we can return from the schedule(); call also because of such a signal, and as a consequence we must also control if there are signals pending. If there are, it means that the "open" system call (als4000_dsp_open( ) ) will fail but, depending on what error we return from the "open" system call, the kernel may behave differently. In particular if we return the -ERESTARTSYS error the kernel will restart the system call calling again the "open" function. This is quite a standard behavior for device drivers.
As already noted we also need to remove the awakened process' descriptor from the waiting queue. Failing to do so here, or having left the for() endless loop, will cause a system crash if and when the scheduler will try to run our process after a device closing, from another process, will have " awakened" it. The crash can be caused by the kernel trying to run a process that could have already ended its life cycle or a process that is still running but in a phase of its execution thread that is not compatible with its presence in our wait queue.
So returning from the schedule(); call, and if we are really being awakened by another process closing the device, we break and exit the for() loop having already acquired the semaphore ( down(&p->open_sem); at the end of the for() loop ).
Note that placing the set_current_state(TASK_INTERRUPTIBLE); call after the up(&p->open_sem); can cause a race condition to happen. In fact this will cause the
if (!(p->dsp_open_fmode & file->f_mode)) break;
to be evaluated before the set_current_state(TASK_INTERRUPTIBLE);. Suppose the if() evaluation says we shall sleep and suppose another process closes the device just after the if()check, but before we put our process in a TASK_INTERRUPTIBLE state; we are convinced that we shall go to sleep, while in fact the device has been closed and could possibly be opened again by our process. So we go to sleep but we miss a "close" operation and as a result we could never be awakened again.
The call to set_current_state( TASK_INTERRUPTIBLE ); is almost equivalent to writing current->state = TASK_INTERRUPTIBLE; but with the difference that the CPU is forced to finish the task of setting the current->state field before it is allowed to execute the following instructions. This is necessary since modern processor architectures have an high degree of internal parallelism to accelerate the execution flow. This parallelism may alter the strictly sequential execution order of our written instructions, so this function has here an important role in avoiding a race condition.
Please note also that the code of our driver is run in the context of a process but in kernel mode, and for that reason it's not pre-emptible (i.e. can't be stopped by an interrupt). To service an interrupt we must wait until the process returns in user mode.
When we finally leave the for(;;) loop, we reset the process to a TASK_RUNNING state and remove it from the wait queue, before leaving the block of code
if (p->dsp_open_fmode & file->f_mode) { ... }
We now could open the DSP in RD or WR mode. If we want to open for writing we need to initialize the data structure that are needed for the playback of audio files on the device.
This is accomplished by calling the init_dma() function. This function is defined in our als4000_dsp.c file and returns 0 if it executes correctly, otherwise it returns an error code ( -ENOMEM ) that we will pass back as the return value of the als4000_dsp_open( ) call.
If init_dma() returns 0, before exiting the "open" call we update the opening mode of our DSP by setting the dsp_open_fmode field of the driver's peripheral descriptor. This is done with :
p->dsp_open_fmode |= file->f_mode & (FMODE_READ|FMODE_WRITE);
In this line of code on the right of |= we mask all the bits in the file->f_mode element with the exception of the READ bit and the WRITE bit, then we assign to
p->dsp_open_fmode the value resulting from the bitwise OR of itself with the masked
file->f_mode element.
The init_dma() function takes two arguments, the first is a pointer to a "struct als4000_dma" data structure representing our DMA buffer and defined in the als4000.h file. The second argument is an integer representing the buffer dimension, in term of the number of page frames (4K Bytes each) that constitute the buffer. This value is 2 by default in our case, giving a buffer length of 8 KB, and is set as a parameter of the driver (see the als4000_main.c file) that can be overridden at load time when the driver is compiled as a module.
The "struct als4000_dma" buffer descriptor contains all the elements that are needed by our driver to correctly manage the transfer of data, to and from the buffer.
The first element needed is a pointer to the memory area that we allocate for the buffer. As we will see, we are going to manage our buffer as a "ring buffer" (i.e. the last +1 buffer address equals the first buffer address).
The second element describes the length of the buffer in bytes and it's needed since we may have a different buffer length for the output (playback) buffer and for the input (recording) buffer. Then we have a group of pointers that are needed to properly manage our circular buffer (see fig.1).
Fig.1 the circular buffer
The area of the buffer that is used at any one moment by the hardware responsible for the DMA transfer is delimited by *dma_start and *dma_end. Considering for example the case of the playback function, where the DMA controller transfers data from our RAM buffer to the DSP, these elements point respectively to the beginning and to the end of the buffer area that's active from the point of view of the DMA controller (in particular *dma_end points to the position after the last byte of a DMA transfer, which is the first position of the filled area ) .
As the DMA controller proceeds in transferring data from the DMA active zone, our driver shall write into the buffer starting immediately after the address pointed by *dma_end and up until an address pointed by *free-start (possibly folding back over the first address of the driver).
All the pointers (with the exception of *buffer) move to the right and the free zone is the only one available to our driver for writing into. The "filled" zone contains data that has been stored for playback, but for which a DMA transfer has not yet being initiated.
We use such a circular buffer since this is a data structure that protects itself from race conditions when we update the pointers. As we shall see, our als4000_dsp_write() system call will update only the *free_start pointer that can be moved to the right only until it reaches the position just before the one pointed by *dma_start. Furthermore only the als4000_dsp_write() system call will be allowed to change the *free_start pointer, while the *dma_start and *dma_end pointers can only be changed by the interrupt handler (see als4000_inthandl( ) in als4000_main.c ).
If for example the als4000_dsp_write() system call reads the value of *dma_start (as already said, it will never be allowed to change this value) just before the interrupt handler changes it, then the "write" system call will have a conservative idea of the extension of the "free area", since the *dma_start pointer has now moved to the right, but this doesn't create any problem with the functioning of the DSP, so we have no race conditions.
Now suppose that our als4000_dsp_write() system call has filled all the available buffer space (i.e. the "free" area is empty as in fig.2 ). In this case the system call shall block, waiting for the DMA controller to complete the transfer, so that our interrupt handler can update (push to the right) the *dma_start pointer. To block we need another wait queue, so within the struct als4000_dma we define a wait_queue_head_t wait element. This wait queue will be used to block a process waiting for the event that the "free" area becomes not empty.
fig.2 free area is empty
Another element in struct als4000_dma is needed to store the first address of our buffer as it is viewed by the DMA controller. As we have seen in Lesson 07, the address space of the DMA controller is in general different from the linear address space of RAM as seen by the CPU. So we need the pointer *buffer to store the starting address of our buffer from the point of view of the CPU and the dma_addr_t dma_start_handle to store the same address, but from the point of view of the DMA controller (i.e. in a different address space).
Remember that each time we introduce a new data structure we must also take care of its initialization (i.e. to state that the wait queue is empty, or that a semaphore is up and so on), and a good point were we can do such initialization is within the "probe" system call in the als4000_main.c file.
The "probe" system call is invoked by the kernel PCI subsystem when the kernel loads a driver (if complied as a module). So in our als4000_probe()system call we add, among others, a few initialization instructions as :
p->dma_dac.rate = ALS4000_DEFAULT_RATE;
p->dma_dac.buffer = NULL;
where p is our peripheral descriptor (struct als4000_descr *p) and dma_dac is the element that represents our circular buffer within the peripheral descriptor.
Lets now have a look at the operations we do inside our init_dma() system call.
The first instruction :
char *b = (char * ) __get_dma_pages(GFP_KERNEL, order);
allocates 2 ( order ) contiguous page frames, for a total of 8 Kbytes to the buffer. The __get_dma_pages( ) allocates page frames in the first 16 Mbytes of RAM. This is needed since our audio PCI board has a DMA controller with only 24 bit on its address bus. The GFP_KERNEL parameter in the call means that we are allocating memory to the kernel and that, if memory is not available, we are allowed to block waiting for the memory to become available. Remember that if we are requesting memory within an interrupt handler we use GFP_ATOMIC, otherwise we use GFP_KERNEL.
The __get_dma_pages( ) call returns the linear address of the first byte of the buffer and we do a check to see if it's not null.
If we do get memory for our buffer, we set the buflen element of the struct als4000_dma that was passed as the first parameter of the init_dma( ) call. This is done with a left shift of 12 bit positions (equivalent to multiplying by 4096, or 4K) of the value in order, which is 2 by default.
Then we set all the pointers to the value of b, that's the initial address of the buffer:
d->buffer = d->free_start = d->dma_start = d->dma_end = b;
and we initialize the wait queue
init_waitqueue_head(&d->wait);
At this point we have allocated and initialized the buffer.
To complete initialization we must add (and we do this within the "probe" function) a call to
pci_set_master (pcidev); . This is needed to enable the PCI bus mastering capabilities of the peripheral. Finally we shall instruct the kernel's PCI layer about the bus address size (in our case 24 bit) supported by the peripheral with
pci_set_dma_mask(pcidev, 0x00ffffff) and check for a return error code of 0. If an error arises then it must be handled ( goto error_dma ) by undoing all the things that have been done before the error happened.
Implementation of the als4000_dsp_release( ) system call
Now we need to consider what's to be done when a process closes our device file. The als4000_dsp_release( ) system call obtains the device descriptor from the
struct file *file parameter
struct als4000_descr *p=(struct als4000_descr *) file->private_data;
Then we must protect the device file from race conditions that can arise if two or more processes are trying to open or close the device file concurrently, and we do this with
down(&p->open_sem); . Furthermore if the process that's closing our device file did an open for write (remember that we store this information in file->f_mode), then we shall de-initialize the DMA data structures with :
deinit_dma(&p->dma_dac, dac_buf);
After this we must reset the content of p->dsp_open_fmode as we did on exiting the "open" system call. We do this by first complementing the result of
file->f_mode & (FMODE_READ|FMODE_WRITE)
and then by evaluating the bitwise AND between this complemented result and
p->dsp_open_fmode , substituting it with the resulting bit pattern. In so doing we set to logical 0 the bit that, in p->dsp_open_fmode , indicates how the device was opened.
We then reach the crucial point where we need to wake up the processes that are sleeping waiting for the device to be openable in the way they need. We do this with
wake_up(&p->open_sem);
Before returning from the als4000_dsp_release( ) system call we now just need to reset the semaphore.
When a process wakes up and is scheduled to run it returns from the schedule(); system call in als4000_dsp_open() and it will try to acquire the semaphore since this is needed before manipulating critical data structures. If the process fails in acquiring the semaphore it blocks (inside the down(&p->open_sem); system call) waiting for the semaphore. To increase the probability of this not happening we may call up(&p->open_sem); before
wake_up(&p->open_sem); since this doesn't cause race conditions.
As for the implementation of deinit_dma() we just need to free the resources allocated to the DMA (i.e. the memory pages used to implement our circular buffer) and we set to NULL all the pointers needed for the operation of the circular buffer.
Since the speed at which the CPU can fill the buffer is of the order of micro-seconds while the DSP that's reproducing a sound file is capable of consuming an entire buffer content only at the rate of a few milli-seconds, we clearly have a problem when we try to free the memory allocated to the buffer.
So the last als4000_dsp_write() system call of a process trying to playback an audio file will return when the DSP has still a lot of job to do. We may then be calling als4000_dsp_release() , and consequently deinit_dma(), well before the DSP had enough time to empty the buffer.
To solve this problem we use a call to wait_event(); before calling free_pages(); .
The first argument in wait_event(d->wait, !dma_running(d)); indicates the queue where the process shall wait, and the second argument indicates the condition that must be verified to stop waiting. In our case we can stop waiting as soon as the DMA is not running anymore. The DMA is running if *dma_start is different from *dma_end , so when *dma_start equals *dma_end we can stop waiting (function dma_running() is defined in the als4000.h file).
The wait_event() call is equivalent to all the code in our als4000_dsp_open() that's in the block of the
if (p->dsp_open_fmode & file->f_mode) { ... }
and we could have simply used it , were we not forced to use the semaphore.
This ends the definition of the "open" and "release" system call for our device, but to get a sound from the DSP we must do some more initialization depending on what's found on the technical manual of our sound card. We decided to put all this definitions and initializations, that are hardware dependent, in the als4000.h file. As an example, reading the technical manual, we see what sound formats the board supports (PCM unsigned 8bit, PCM signed 8bit, PCM unsigned 16 bit and PCM signed 16 bit), also we may have both mono or stereo streams of sound samples, and if they are stereo we must define the formats for each stream.
Other important definitions relate to the addresses of the IO ports. As we know, during initialization of the PCI board, we discover the initial address that's allocated to this IO region so, knowing how big is each area (the one for the DSP, the one for the codec, the one for the mixer and so on) we can define macros to refer to each IO area. All this information is also contained in the technical manual.
Another important thing that we know from the technical manual is that, to read a byte from the DSP (called ESP by the manufacturer of this board, that's compatible with a Soundblaster card) we must keep reading a status register in the IO space of our board until we find that its most significant bit is set to 1. When this happens we know that our byte is ready to be read from another register. With this information we implement the
static inline unsigned char esp_read(unsigned long base)
call in the als4000.h file.
In this file you'll find other functions that implement what is indicated in the technical manual for various devices contained in the board.
So we completed the initializations and the definitions of various aspects of the operation of our sound card and in the next lesson we'll proceed with the implementation of the "read" and "write" calls for our DSP device file.